Compare commits

...
14 Commits
Author SHA1 Message Date
ayurishchevandClaude Sonnet 5.5 1366ecbdea Add the admin guide for manual database cleanup (SQL)
docs/ADMIN_CLEANUP.md: what is in the control-api database and what must
not be touched, preparation (stop, backup, checks), ready SQL for a full
reset before a new run, the event log, the check registry, single
addresses and compaction, verification after the cleanup, restore from a
backup, and what to do through the API instead. Every SQL block was run
on a copy of the production backup. Linked from the README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 16:45:59 +03:00
ayurishchevandClaude Sonnet 5.5 0532baff09 Keep one address per validator; fix heartbeat handling and queue clear
A mass check on 2026-10-02 stalled 7 of 20 validators and sent 42
addresses to fail without a single check. A validator busy with slow
checks went silent, was marked unreachable, and its next heartbeat put it
back to idle while it still held the address; it was handed a second one,
whose association never ran (the in-flight guard was keyed by validator),
and both waited for their leases to expire.

- Heartbeat/re-register return an unreachable validator to assigned when
  it still holds an address, else idle.
- A validator is released only from the address it currently holds
  (ReleaseFIP, RequeueOrFail, MarkFIPOccupied, FreeValidator); an
  unreachable validator stays unreachable until its next heartbeat, so a
  dead validator is no longer handed a new address every lease period.
- ClaimNextQueued refuses a validator that still has an address; a
  ReconcileValidators pass on every tick repairs rows that disagree with
  the queue.
- Association guard is keyed by address, not validator.
- The agent sends heartbeats from their own goroutine.
- Clear queue / delete: detach only floating IPs of unfinished rows (done,
  failed and occupied rows kept their fip_id and made a clear issue >1000
  sequential cloud calls: 256 s), at most 8 in parallel; the operation no
  longer dies with the client connection (10 minute limit).

Includes the incident analysis and the plan under analysis/ and
docs/changes/, and rebuilt bin/control-api and bin/validator-agent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 14:42:12 +03:00
ayurishchevandClaude Sonnet 5.5 cf4a883363 ansible: fix container name and git user, refuse to start a second agent
The real container on the validators is named validator-agent (the image
is cloud-ip-validator-validator-agent); the playbook used the image name
as the container name, so it would have started a second agent next to
the old one with the same validator_id. The clone on the validators is
owned by root, so git must run as root (git_user), otherwise fetch fails
with "cannot open .git/FETCH_HEAD: Permission denied".

Preflight now stops when the host has another container of this agent
(by name or image) besides container_name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 10:01:05 +03:00
ayurishchevandClaude Sonnet 5.5 49890ff5de Add Ansible playbook to deliver validator-agent to the validators
Run from the jump host: on each validator it updates the git clone in
/opt/cloud-ip-validator, builds the image there, stops and removes the
current container and starts a new one from the new image. Run
parameters live in an env file (deploy/ansible/env/validator-agent.env,
git-ignored, template committed).

The image is built before the running container is touched, so a failed
build leaves the old container running. Hosts are updated in waves
(1, 4, rest) and any failure stops the run. validator_id comes from the
inventory and is checked against the running container before it is
replaced. Only ansible.builtin modules are used, so the validators need
no extra packages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 09:23:39 +03:00
ayurishchevandClaude Sonnet 5.5 abbee9a08a Add self-check via control-api (self_check.methods)
control-api is hosted outside the cloud and validators reach it directly,
so it sees the floating IP as the connection's source address. New open
route GET /api/v1/agents/{id}/observed-ip returns that address (taken only
from the TCP peer; forwarding headers are ignored so a validator cannot
forge it).

The agent gets self_check.methods, a priority-ordered list of ip_echo
(unchanged) and control_api; the default stays [ip_echo]. The self-check
passes when any method confirms the address; the next method is tried on
no answer and on a mismatch. Each method has its own timeout so a hung
first method cannot starve the fallback, and control_api uses a new TCP
connection per call (a connection opened before the floating IP was
attached would keep reporting the old address).

Also: docker agent template/env, example config, docs, plan in
docs/changes, e2e script switch E2E_SELF_CHECK_METHODS, rebuilt
bin/control-api and bin/validator-agent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 03:24:20 +03:00
ayurishchevandClaude Sonnet 5.5 146259cabb Detach the floating IP when a self-check fails
A failed self-check returned the address to the queue and freed the
validator in the database, but left the floating IP attached to the
validator's port. Every later association on that port then failed with
409 ("fixed IP already has a floating IP"), so one failed self-check
poisoned a validator for good; on 2026-10-01 all 20 validators were
poisoned within 23 minutes after ifconfig.me timeouts.

SelfCheckResult now disassociates the floating IP before requeueing, and
ignores a late failed report for an address the validator no longer
holds (it could belong to another validator by then).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 03:24:19 +03:00
ayurishchevandClaude Sonnet 5.5 1ee5757004 Free validator ports in the cloud on clear/cancel/delete
An address still being associated (assigning_fip) has no fip_id in the
database, so "clear queue" did not detach it, and the association then
finished after the row was gone, leaving the floating IP on the validator
port for good.

- After clear/cancel/delete, ask the cloud which floating IPs sit on the
  affected validator ports (new ListFloatingIPsByPort) and detach those
  that this system queued (known in ip_registry); foreign ones are left.
- SetFIPAssociated applies only to a row still in assigning_fip; if the
  address was removed meanwhile, associateFIP detaches the floating IP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 21:00:31 +03:00
ayurishchevandClaude Sonnet 5.5 c0e300f71c Run per-validator OpenStack work in parallel so all validators start at once
The orchestrator claimed idle validators one by one and associated each
floating IP synchronously (~30 s per address), so 20 validators started
about 30 s apart. Aggregation/disassociation and lease reclaim were
sequential in the same way.

The slow OpenStack calls now run in one goroutine per address, guarded by
an in-flight set against duplicates. control-api runs Tick in Async mode
(Tick does not wait); tests keep the waiting mode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 20:37:32 +03:00
ayurishchevandClaude Sonnet 5.5 aff8fe38b5 Scan floating IPs in the background, page by page, so thousands of addresses work
The "Scan Floating IP" button failed with a client timeout: the project now
holds ~6.4k floating IPs and the scan listed them all in one unpaginated,
timeout-less Neutron request on the HTTP request context.

openstack: ListFreeFloatingIPs reads marker-based pages (fields= keeps them
small) with per-page retry/backoff on transport errors, 5xx and 429, and every
request now has a timeout (also ends hangs inside the orchestrator tick).

orchestrator: the scan is a single-flight background job on the process
context with progress (clearing/listing/enqueuing/done/error), dry_run, full
discovery before anything is enqueued, then SubmitIPs in chunks of 500 in
ascending IP order; a failed read leaves the queue untouched. The auto-cycle
gets a "scanning" phase that polls the job, so the control loop and
autoCycleMu are never held across OpenStack/DB work; it recovers after a
restart and waits for (instead of adopting) a scan started by someone else.

db: migration 0009 (indexes), paged ListIPsPage/ListRegistryPage, GROUP BY
counters, EXISTS completion check, set-based ClearAllIPs.

API: POST /admin/ips/scan -> 202 (dry_run, wait), GET /admin/ips/scan, paging
and filters on /admin/ips and /admin/registry (bare arrays without limit),
results_by_overall in /admin/status.

dashboard: scan progress panel and dry-run button, paginated /ips and
/registry with server-side filters, Overview on counters and capped lists
with progress/ETA, "select all N by filter", hx-params fix for per-row
buttons, real counts in confirmations.

Also: docs (API, USAGE, DASHBOARD, README), plan and review under
docs/changes/, bin/ rebuilt with new SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 19:31:11 +03:00
ayurishchevandClaude Sonnet 5.5 debf2afed2 Add authentication: admin/agent bearer tokens for the API, login for the dashboard
control-api: every route now carries a mandatory access level (admin / agent /
open) in a route table. All /api/v1/admin/* require the admin token; the
write calls of validator-agent and prober (self-check, events, results,
complete) require a separate static agent token; register, heartbeat and
fetching the assignment stay open. Tokens come from env vars, are compared in
constant time and never logged. An empty token leaves that level open with a
startup warning (backward compatible).

validator-agent / prober: apiclient sends the agent token only to control-api.

admin-dashboard: login/password (from env) with a stateless HMAC session
cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for
htmx polls, logout in the sidebar; the dashboard calls control-api with the
admin token. Login page layout fixed after review.

Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples,
e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD,
README), plan and review under docs/changes/, bin/ rebuilt with new
SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 11:35:24 +03:00
ayurishchevandClaude Sonnet 5.5 972ad47d0c Restructure README.md into a self-contained overview
Follow the section layout used in the ipam_control project: quick start,
configuration tables, architecture with a directory tree, data-model rules
(queue states, results, registry, auto-cycle), API table, security notes,
operations, dashboard pages, tests, docs index and change history. Details
stay in docs/ and are linked rather than duplicated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:48:16 +03:00
ayurishchevandClaude Sonnet 5.5 7b34b640f5 rxprod-compose: publish admin-dashboard on host port 8091
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:44:31 +03:00
ayurishchev 48efa61ef3 Merge pull request 'Add optional automatic check cycle (clear queue -> scan FIPs -> wait -> repeat)' (#2) from feat/auto-cycle into main
Reviewed-on: #2
2026-10-01 10:33:38 +03:00
ayurishchevandClaude Sonnet 5.5 cd37b10f3b Add optional automatic check cycle (clear queue -> scan FIPs -> wait -> repeat)
An admin-controlled scenario that repeats what the operator does by hand:
clear the IP queue, scan and enqueue all free Floating IPs, wait until every
queued address reaches a terminal state (so results are in the Registry),
then wait a configurable interval and start over.

- control-api: new auto_cycle singleton table (migration 0008) holding
  enabled/interval/max-run settings and persisted phase state, so the cycle
  survives restarts; engine in orchestrator/autocycle.go driven from the
  existing loop tick with an injectable "now" for deterministic tests.
- Interval (default 1h, min 60s) and max wait (default unlimited, timeout
  outcome) are runtime settings, never hardcoded.
- The periodic fip_scan_interval_seconds scan is skipped while the cycle is
  enabled. An emptied queue mid-cycle counts as finished; stopping during
  the pause keeps the last cycle's outcome.
- API: GET/PUT /api/v1/admin/auto-cycle, POST .../start, POST .../stop.
- admin-dashboard: "Автоматический цикл" panel on /settings and an
  "Автоцикл активен" indicator on /overview.
- Tests for db, orchestrator, httpapi and dashboard; run-local-e2e.sh now
  exercises a full auto cycle; docs updated; bin/ rebuilt with refreshed
  SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 10:28:53 +03:00
139 changed files with 13598 additions and 623 deletions

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
!/deploy/docker/.env.example
!/deploy/docker/.env.prod.example
/deploy/docker/control-api/control-api.docker.yaml
# Ansible: рабочий env-файл запуска validator-agent (шаблон .example остаётся в git).
/deploy/ansible/env/*.env
/rxprod-compose/.env
# Live runtime database for the rxprod-compose control-api container.
+196 -36
View File
@@ -1,54 +1,214 @@
# Cloud IP Validator
Система проверки освобождённых публичных IPv4-адресов перед их повторной
выдачей: каждый адрес временно привязывается как Floating IP к
ВМ-валидатору в облаке (OpenStack) и проверяется одновременно в двух
направлениях — исходящий трафик валидатора (egress: HTTPS/ICMP/опционально
SSH до внешних целей) и входящая доступность самого адреса с нескольких
независимых внешних площадок (inbound: TCP 22/80/443/8080 + ICMP). Итог по
каждому адресу — `pass`/`partial`/`fail`, с полной историей проверок,
которая сохраняется даже после того, как адрес убрали из очереди (см.
[«Реестр адресов»](docs/USAGE.md#реестр-адресов-и-глубина-истории)).
Система проверки освобождённых публичных IPv4-адресов перед их повторной выдачей. Каждый адрес временно привязывается как Floating IP
к ВМ-валидатору в облаке (OpenStack) и проверяется в двух направлениях одновременно: исходящий трафик валидатора (egress: HTTPS/ICMP/опционально SSH
до внешних целей) и входящая доступность самого адреса с нескольких независимых внешних площадок (inbound: TCP 22/80/443/8080 + ICMP).
Итог по каждому адресу — `pass`/`partial`/`fail`; полная история проверок сохраняется в реестре даже после удаления адреса из очереди.
## Роли компонентов
## Быстрый старт
```bash
go build ./... && go test ./... # сборка и юнит-тесты
scripts/run-local-e2e.sh # офлайн-прогон всей системы: mock OpenStack, без облака и интернета
```
```bash
cd deploy/docker
cp .env.example .env
cp control-api/control-api.docker.example.yaml control-api/control-api.docker.yaml
docker compose up -d --build # весь стенд на одной машине в mock-режиме (профили control-plane, dashboard, prober, validator)
```
- UI: `http://<хост>:8090/`, API: `http://<хост>:8080/api/v1`, проверка живости: `GET /healthz`.
- Docker-стенд работает с `openstack.mode: mock` и тестовыми адресами `203.0.113.10–12`; реальный OpenStack и учётные данные не нужны.
- Реальный стенд (systemd или Docker на нескольких хостах) — по шагам в [docs/SETUP.md](docs/SETUP.md).
## Конфигурация
Каждый компонент читает свой YAML (примеры — `configs/*.example.yaml`). Учётные данные OpenStack в YAML не хранятся — только *имена* переменных окружения.
**control-api (`control-api.yaml`)**
| Ключ | Назначение |
|---|---|
| `server.listen_addr` | Адрес API (`:8080`) |
| `database.path` | Файл SQLite (`/var/lib/cloud-ip-validator/control-api.db`) |
| `openstack.mode` | `real` или `mock` (встроенная заглушка OpenStack для разработки и тестов) |
| `openstack.auth_method` | `token` (готовый токен проекта из `OS_TOKEN`, сам не обновляется) или `password` (логин/пароль Keystone, токен перевыпускается автоматически) |
| `openstack.*_env` | Имена переменных окружения: `OS_AUTH_URL`, `OS_PROJECT_ID`, `OS_REGION_NAME`, `OS_INTERFACE`, `OS_TOKEN` либо `OS_USERNAME`/`OS_USER_DOMAIN_NAME`/`OS_PASSWORD` |
| `orchestrator.poll_interval_seconds` | Период такта оркестратора (5) |
| `orchestrator.self_check_timeout_seconds`, `max_self_check_retries` | Ожидание self-check валидатора (60) и число его повторов (3) |
| `orchestrator.checking_window_seconds` | Сколько ждать результаты проверок, прежде чем подвести итог (120) |
| `orchestrator.lease_ttl_seconds`, `max_retries` | Лизинг адреса за валидатором (180) и число возвратов в очередь при его истечении (3) |
| `orchestrator.heartbeat_timeout_seconds` | После скольких секунд тишины валидатор или площадка считаются потерянными (30) |
| `orchestrator.fip_settle_seconds` | Только начальное значение: пауза между привязкой FIP и self-check; далее управляется на лету. Должно выполняться `fip_settle_seconds + self_check_timeout_seconds < lease_ttl_seconds` |
| `orchestrator.fip_scan_interval_seconds` | Периодический скан Floating IP (0 — выключен). Не выполняется, пока включён автоматический цикл |
| `orchestrator.fip_scan_timeout_seconds` | Предел всего сканирования Floating IP (1800) |
| `openstack.list_page_size`, `list_page_retries`, `request_timeout_seconds` | Скан читает Floating IP страницами (200), повторяя страницу при обрыве/5xx/429 (5 раз); таймаут одного запроса к OpenStack (60 с) |
| `auth.admin_token_env`, `auth.agent_token_env` | Имена переменных окружения с токеном администратора (`CONTROL_API_ADMIN_TOKEN`) и токеном агентов (`CONTROL_API_AGENT_TOKEN`). Значения в YAML не хранятся; пустой токен — соответствующий уровень API открыт (с предупреждением в логе) |
| `aggregation.missing_counts_as_fail` | Отсутствие ответа источника засчитывается как провал (`true`) |
| `validators`, `sites`, `check_types`, `targets`, `inbound_checks` | Начальная загрузка пустой БД: валидаторы (`validator_id` + `os_port_id`), внешние площадки, типы и цели egress-проверок, порты inbound-проверок. Дальше источник истины — БД, правки через API/UI |
| `ip_addresses` | Адреса, которые доливаются в очередь при каждом старте (только новые) |
**Остальные компоненты**
| Компонент | Ключи |
|---|---|
| `validator-agent` | `validator_id`, `control_api_url`, `control_api_token_env` (`CONTROL_API_AGENT_TOKEN`), `poll_interval_seconds`, `self_check.*` (таймаут, `methods` — `ip_echo`/`control_api`, `ip_echo_urls`), `checks.*` (таймауты HTTPS/ICMP, число ICMP-пакетов, `ssh.*`) |
| `prober` | `site_id`, `control_api_url`, `control_api_token_env` (`CONTROL_API_AGENT_TOKEN`), `poll_interval_seconds`, `checks.*` (таймауты TCP/ICMP, число ICMP-пакетов) |
| `admin-dashboard` | `server.listen_addr` (`:8090`), `control_api.base_url`, `control_api.timeout_seconds`, `control_api.token_env` (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`), `auth.username_env` / `password_env` / `session_secret_env` (`ADMIN_DASHBOARD_USERNAME` / `_PASSWORD` / `_SESSION_SECRET`), `auth.session_ttl_minutes` (480), `overview.last_completed_count` (20), `overview.poll_interval_seconds` (5) |
Секреты (токены, пароль дашборда, ключ сессии) задаются **только переменными окружения**; генерация — `openssl rand -hex 32`. Подробности и порядок включения — [docs/SETUP.md](docs/SETUP.md#5-аутентификация-токены-и-пароль-дашборда).
Настройки, меняющиеся на лету (пауза перед self-check, глубина истории, типы inbound-проверок, автоматический цикл, валидаторы, площадки, цели), хранятся в БД и правятся через API или страницу `/settings`.
## Архитектура
| Слой | Технологии |
|---|---|
| Backend | Go 1.26, `net/http` (маршруты Go 1.22), SQLite (чистый Go-драйвер `modernc.org/sqlite`, WAL, одно соединение), встроенные миграции `0001`–`0008` |
| Облако | Интерфейс `FloatingIPClient`: реальный клиент OpenStack Neutron и `MockClient` |
| UI | Серверный рендеринг (Go-шаблоны) + htmx и Alpine.js, шрифты и скрипты лежат в репозитории, внешних зависимостей нет |
| Сборка | Статические бинарники (`CGO_ENABLED=0`) в `bin/`, Docker-образы, systemd-юниты |
| Компонент | Роль | Состояние | Где работает |
|---|---|---|---|
| `control-api` | Управляющий сервис: ведёт очередь адресов и реестр их истории, назначает валидаторов, агрегирует результаты. Единственная точка, с которой общаются все остальные компоненты. | Хранит (SQLite) | Одна управляющая машина |
| `validator-agent` | Привязывает к себе выданный Floating IP и прогоняет egress-проверки до внешних целей. | Без состояния | Каждая ВМ-валидатор в облаке |
| `prober` | Проверяет входящую доступность адреса снаружи (TCP/ICMP) — с независимой от облака сети. | Без состояния | Каждая внешняя тестовая площадка |
| `admin-dashboard` | Браузерная админ-панель — то же самое, что доступно через API `control-api`, но графически. Опционален. | Без состояния | Любая машина с сетевым доступом к `control-api` |
| `control-api` | Ведёт очередь адресов и реестр их истории, назначает валидаторов, агрегирует результаты. Единственная точка, с которой общаются все остальные компоненты | Хранит (SQLite) | Одна управляющая машина |
| `validator-agent` | Привязывает к себе выданный Floating IP и прогоняет egress-проверки до внешних целей | Без состояния | Каждая ВМ-валидатор в облаке |
| `prober` | Проверяет входящую доступность адреса снаружи (TCP/ICMP) — с независимой от облака сети | Без состояния | Каждая внешняя тестовая площадка |
| `admin-dashboard` | Браузерная админ-панель — то же, что API `control-api`, но графически. Опционален | Без состояния | Любая машина с доступом к `control-api` |
Все четыре общаются между собой **только** через HTTP API `control-api` —
прямых связей между остальными компонентами нет. Подробная схема
control/data plane — в [docs/DIAGRAMS.md](docs/DIAGRAMS.md).
Все четыре общаются **только** через HTTP API `control-api`. Pull-модель: `validator-agent` и `prober` сами опрашивают `control-api`, он к ним не обращается.
Схемы control/data plane — [docs/DIAGRAMS.md](docs/DIAGRAMS.md).
```
cmd/ control-api validator-agent prober admin-dashboard
internal/ config db orchestrator httpapi openstack dashboard agentcore probercore checkrunner apiclient
db/migrations/ схема SQLite (0001–0008)
configs/ примеры конфигураций компонентов
deploy/ docker/ (compose, Dockerfile, RUN.txt) systemd/ (юниты)
rxprod-compose/ compose реального стенда (control-api на :8081, дашборд на :8091)
scripts/ run-local-e2e.sh httpstub/ (заглушки целей для e2e)
bin/ готовые бинарники linux/amd64 и SHA256SUMS
docs/ документация и планы доработок
```
## Модель данных и правила
`ip_queue` (текущая очередь) · `ip_registry` (все адреса, когда-либо бывшие в очереди) · `checks` (результаты по циклам) · `events` (журнал) · `validators` · `sites` · `target_groups` · `check_types` · `settings` · `inbound_checks_settings` · `auto_cycle`.
**Очередь и состояния**
- Путь адреса: `queued` → `assigning_fip` → `awaiting_self_check` → `checking` → `aggregating` → `done` или `failed`. Все переходы, кроме команд оператора, выполняет оркестратор сам по таймеру.
- Терминальные состояния: `done`, `failed`, `occupied`. `occupied` — Floating IP к моменту привязки уже занят чужим портом: цикл проверки не стартует, `overall_result` пуст, это не `fail`.
- Оператор может отменить проверку (`failed` + `cancelled`), перепроверить завершённый адрес (возврат в `queued`) или удалить адрес из очереди.
- Адреса обрабатываются в порядке `sequence`; каждый свободный валидатор получает следующий `queued`-адрес под лизинг. Истёкший лизинг возвращает адрес в очередь, после `max_retries` — в `failed`.
**Итоговый результат**
- `pass` — прошли все проверки: исходящие и все настроенные площадки по всем портам и ICMP. `partial` — часть прошла, часть нет. `fail` — не прошла ни одна проверка или адрес не дошёл до проверок (self-check не подтвердился). `cancelled` — остановлено оператором.
- Итог подводится, когда отчитались валидатор и все настроенные площадки либо истекло `checking_window_seconds`. Молчание источника — провал (`aggregation.missing_counts_as_fail`).
- Площадки опциональны: с пустым списком `sites` итог строится только по egress-проверкам.
**Реестр**
- Запись реестра создаётся при первой постановке адреса и не удаляется: она переживает удаление из очереди и повторное добавление.
- История проверок хранится по циклам; `history_retention_cycles` ограничивает глубину (0 — без ограничения), сама запись реестра остаётся.
**Автоматический цикл** (опционально, по умолчанию выключен)
- Один цикл: очистить очередь → просканировать Floating IP (фаза `scanning`, фоновое задание) → дождаться, пока все адреса станут терминальными → пауза `interval_seconds` → заново. Пауза считается от завершения цикла. Сканирование не блокирует оркестратор; чужое идущее сканирование цикл дожидается, а не присоединяется к нему.
- `interval_seconds` — по умолчанию 3600, минимум 60; `max_run_seconds` — максимальное ожидание проверок (0 — без лимита), по истечении исход `timeout`.
- Исходы цикла: `completed`, `no_free_ips`, `timeout`, `error`, `stopped`. Опустевшая очередь посреди цикла считается завершением.
- Состояние хранится в БД и переживает перезапуск; выключение не прерывает идущие проверки. Подробности — [docs/USAGE.md](docs/USAGE.md#автоматический-цикл-проверок).
**Удаление и сканирование**
- Удаление (точечное, списком, «очистить всё») убирает строку очереди, но не историю в реестре.
- Скан Floating IP ставит в очередь только свободные адреса (не привязанные ни к одному порту); уже идущие проверки не трогаются. Он идёт **в фоне и читает облако страницами**: подходит и для тысяч адресов (на стенде 6441 Floating IP читаются ≈ 1,5–2 мин). Адреса ставятся в очередь только после полного обнаружения (кусками по 500, по возрастанию IP); при сбое чтения очередь не меняется. `dry_run=true` / «Пробное сканирование» считает адреса, не меняя очередь.
- Пропускная способность: ≈ 50 с на адрес на валидатор — очередь из 6440 адресов это ≈ 18 ч на 5 валидаторах, ≈ 9 ч на 10 (рычаги: число валидаторов и `fip_settle_seconds`).
## API (`/api/v1`)
| Область | Эндпоинты |
|---|---|
| Валидатор | `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`, `POST /agents/{id}/self-check\|events\|results\|complete` |
| Пробер | `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments`, `POST /probers/{site_id}/results` |
| Очередь | `GET /admin/status`, `GET\|POST /admin/ips` (`limit/offset/state/q/result/order` — постранично), `GET /admin/ips/{ip}`, `POST /admin/ips/{ip}/cancel`, `DELETE /admin/ips/{ip}`, `POST /admin/ips/delete\|clear`, `POST\|GET /admin/ips/scan` (фоновый скан: `202`, `dry_run`, `wait`; статус и прогресс) |
| Реестр | `GET /admin/registry` (`limit/offset/q/last_result` — постранично), `GET /admin/registry/{ip}` |
| Автоцикл | `GET\|PUT /admin/auto-cycle`, `POST /admin/auto-cycle/start\|stop` |
| Конфигурация | `/admin/config/validators`, `/sites`, `/targets`, `/check-types`, `GET\|PUT /admin/config/orchestrator`, `GET\|PUT /admin/config/inbound-checks` |
| Служебное | `GET /admin/validators`, `GET /healthz` |
**Соглашения**
- Тело запросов и ответов — JSON. Успех — `200`, `204` — когда данных нет (например, у валидатора нет назначения).
- Ошибки — `4xx`/`5xx` с телом `{"error": "..."}`; нет или неверен токен — `401` (с `WWW-Authenticate: Bearer`), неизвестная сущность — `404`, конфликт состояния — `409`, неверные данные — `400`, ошибка OpenStack при скане — `502`.
- Токен передаётся заголовком `Authorization: Bearer <токен>` ([docs/API.md](docs/API.md#аутентификация)).
- Времена — RFC 3339. Полная спецификация и примеры `curl` — [docs/API.md](docs/API.md).
## Безопасность
- **Доступ к API — два статических Bearer-токена** (без срока жизни, из переменных окружения, сравнение в константное время):
| Уровень | Токен | Методы |
|---|---|---|
| admin | `CONTROL_API_ADMIN_TOKEN` | все `/api/v1/admin/*` |
| agent | `CONTROL_API_AGENT_TOKEN` | запись результатов валидатора и пробера: `self-check`, `events`, `results`, `complete` |
| открыто | — | `GET /healthz`, `register`, `heartbeat`, получение задания (`GET assignment` / `assignments`) и `GET /agents/{id}/observed-ip` |
Токены разные: административный не открывает методы агентов, и наоборот. Валидатор и пробер получают настройку и задание без токена, но не могут отправить результат без токена агентов.
- **Пустой токен — уровень открыт** (обратная совместимость): `control-api` стартует с предупреждением в логе. На реальном стенде задайте оба токена и ограничьте доступ на уровне сети ([docs/SETUP.md](docs/SETUP.md#сетевые-доступы)); токены идут открытым текстом без TLS — публикуйте через reverse-proxy с TLS. Включать токен агентов нужно **после** его раздачи валидаторам и проберам ([порядок](docs/SETUP.md#5-аутентификация-токены-и-пароль-дашборда)).
- **Дашборд закрыт логином и паролем** (один администратор; пароль и ключ сессии — из env). Сессия — подписанная cookie (`HttpOnly`, `SameSite=Strict`, без состояния на сервере), CSRF-защита по `Origin`, 5 неудачных входов за 10 минут с одного IP → `429`. Без заданных логина/пароля дашборд открыт (с предупреждением в логе). Дашборд ходит в API с токеном администратора. Подробности — [docs/DASHBOARD.md](docs/DASHBOARD.md#вход-и-сессия).
- Учётные данные OpenStack передаются только через переменные окружения процесса (`EnvironmentFile=` в systemd, `OS_*` в Docker) и не попадают в YAML; режим `password` перевыпускает токен сам, режим `token` — нет.
- Компоненты работают по pull-модели: на валидаторах и площадках не нужно открывать входящие порты для `control-api`.
- Бинарники статические, без `cgo`; целостность проверяется `sha256sum -c bin/SHA256SUMS`.
## Публикация и эксплуатация
- **Бинарники.** Готовые linux/amd64 лежат в `bin/` и **не обновляются автоматически**: после правок кода пересоберите их и обновите `SHA256SUMS` (команды — [docs/SETUP.md](docs/SETUP.md#вариант-b-сборка-из-исходников)); Dockerfile копируют именно `bin/*`.
- **systemd.** Юниты в `deploy/systemd/`; у `control-api` — `EnvironmentFile` с учётными данными OpenStack.
- **Docker.** `deploy/docker/docker-compose.yml` + `docker-compose.override.yml` (dev, mock) или `docker-compose.prod.yml` (без публикации портов, `restart: unless-stopped`). Какие сервисы поднимаются на хосте, задаёт `COMPOSE_PROFILES`: `control-plane`, `dashboard`, `prober`, `validator`. БД — volume `cloud-ip-validator-db`. Массовая доставка `validator-agent` на валидаторы (сборка образа на каждом хосте, замена контейнера) — Ansible-сценарий [`deploy/ansible/`](deploy/ansible/README.md).
- **Реальный стенд.** `rxprod-compose/` — compose с готовыми образами, собственным `control-api.yaml` и каталогом БД `capi-db/`; `.env` с учётными данными в репозиторий не входит.
- **Миграции** применяются при старте `control-api`; версия схемы — `PRAGMA user_version`. Начальная загрузка (`validators`, `sites`, `targets`, `check_types`, `inbound_checks`) выполняется только в пустые таблицы.
- Остановка (`SIGTERM`) корректно завершает HTTP-сервер и фоновые циклы. Состояние автоцикла и очереди сохраняется в БД.
## Интерфейс
Страницы `admin-dashboard` (подробно — [docs/DASHBOARD.md](docs/DASHBOARD.md)):
| Страница | Назначение |
|---|---|
| `/overview` | Счётчики и прогресс («Готово D из T», оценка времени), «в работе», «в очереди: Q», «последние завершённые», поиск по IP и фильтр по статусу, индикатор скана и автоцикла; работает на счётчиках и ограниченных списках, поэтому быстрый и при тысячах адресов |
| `/ips`, `/ips/{ip}` | Очередь **постранично** с поиском и фильтром на сервере: добавление адресов, «Сканировать Floating IP» (панель прогресса) и «Пробное сканирование», перепроверка, отмена, удаление (страница или «все N по фильтру», «Очистить всё»); детали и события адреса |
| `/registry`, `/registry/{ip}` | Реестр всех адресов (постранично) и полная история проверок адреса; поиск, фильтр и страница сохраняются в адресной строке |
| `/validators`, `/sites`, `/targets`, `/check-types` | Управление валидаторами, внешними площадками, группами целей и типами проверок |
| `/settings` | Панель «Автоматический цикл», пауза перед self-check, глубина истории, TCP-порты и ICMP для inbound-проверок |
- Порядок блоков на `/overview` фиксирован: статистика → фильтр → таблицы; поллится только блок таблиц, поэтому набранный в фильтре текст не сбрасывается.
- Ошибки control-api показываются баннером; при недоступном API страница остаётся рабочей.
- Тёмная и светлая темы, переключатель в шапке.
## Тесты
```bash
go build ./... && go vet ./... && go test ./... # юнит-тесты: db, orchestrator, httpapi, dashboard, agentcore, probercore, checkrunner, openstack
go test -race ./internal/orchestrator ./internal/httpapi ./internal/dashboard ./internal/db
scripts/run-local-e2e.sh # сквозной прогон: lease-reclaim, перепроверка, автоматический цикл
```
Юнит-тесты используют временную SQLite и `MockClient`, внешних ресурсов не требуют. E2E поднимает все компоненты локальными процессами с включёнными токенами (проверяет `401`/открытые маршруты и работу агента и пробера с токеном) и завершается ненулевым кодом при провале проверок автоцикла — [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md).
## Документация
| Документ | Для чего |
|---|---|
| [docs/SETUP.md](docs/SETUP.md) | Развёртывание с нуля: бинарники или сборка из исходников, конфигурация, systemd **и** Docker/docker-compose — пошагово |
| [docs/USAGE.md](docs/USAGE.md) | Повседневная работа: постановка адресов в очередь, сканирование Floating IP, наблюдение за статусом, реестр и история, разбор результатов |
| [docs/USAGE.md](docs/USAGE.md) | Повседневная работа: очередь, сканирование Floating IP, автоматический цикл, статус, реестр и история, разбор результатов |
| [docs/API.md](docs/API.md) | Спецификация HTTP API `control-api` и примеры запросов (curl) |
| [docs/DASHBOARD.md](docs/DASHBOARD.md) | Устройство `admin-dashboard`: страницы, поиск/фильтр, обработка ошибок |
| [docs/DASHBOARD.md](docs/DASHBOARD.md) | Устройство `admin-dashboard`: страницы, поиск и фильтр, обработка ошибок |
| [docs/ADMIN_CLEANUP.md](docs/ADMIN_CLEANUP.md) | Ручная очистка БД администратором (SQL): сброс данных перед новым прогоном, журнал событий, реестр проверок, отдельные адреса, резервная копия и восстановление |
| [docs/DIAGRAMS.md](docs/DIAGRAMS.md) | Диаграммы потоков данных: control plane, egress-проверка, телеметрия |
| [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md) | Полностью офлайн-прогон всей системы одним скриптом — без реального облака и интернета |
| [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md) | Полностью офлайн-прогон всей системы одним скриптом |
| [docs/changes/](docs/changes/) | Планы доработок и отчёты ревью с отметкой времени в имени файла (последняя: [скан при тысячах адресов](docs/changes/2026-10-01_18-59_fip-scan-at-scale-review.md)) |
| [docs/CONTROL_DATA_PLANE.html](docs/CONTROL_DATA_PLANE.html) | Презентационные схемы control/data plane для docker-compose-деплоя — открыть в браузере |
## Быстрый старт
## История изменений
Дизайн крупных доработок зафиксирован в планах `docs/PLAN_*.md`; остальное — в истории git (`git log`).
Посмотреть систему в работе без реального облака (60 секунд):
| Изменение | Документ |
|---|---|
| Веб-панель администратора `admin-dashboard` | [план](docs/PLAN_ADMIN_DASHBOARD.md) · [описание](docs/DASHBOARD.md) |
| Динамическое управление конфигурацией и очередью через API | [план](docs/PLAN_API_CONFIG_MANAGEMENT.md) · [API](docs/API.md#управление-очередью-и-конфигурацией) |
| Удаление адресов: точечное, массовое, «очистить всё» | [план](docs/PLAN_DELETE_IPS.md) |
| Пауза перед self-check после привязки Floating IP (`fip_settle_seconds`) | [план](docs/PLAN_FIP_SETTLE_DELAY.md) · [USAGE](docs/USAGE.md#пауза-перед-self-check-fip_settle_seconds) |
| Механизм аутентификации OpenStack-клиента (token / password) | [план](docs/PLAN_OPENSTACK_AUTH.md) |
```bash
go build ./... && go test ./...
scripts/run-local-e2e.sh
```
Поднимет все компоненты как локальные процессы (`openstack.mode: mock`) и
прогонит тестовый адрес через полный цикл проверки. Подробности —
[docs/LOCAL_E2E.md](docs/LOCAL_E2E.md).
Для реального стенда (systemd-юниты или Docker/docker-compose, на одной
машине или распределённо) — по шагам в
[docs/SETUP.md](docs/SETUP.md), начиная с
[«Получение бинарников»](docs/SETUP.md#получение-бинарников).
| Дата | Веха | Документ |
|---|---|---|
| 2026-10-02 | Валидатор не получает второй адрес при потере heartbeat (иначе адреса уходили в `fail` без проверок); heartbeat агента в отдельном потоке; быстрая очистка очереди, не зависящая от соединения клиента | [план](docs/changes/2026-10-02_09-02_orchestrator-validator-state-and-clear-plan.md) · [анализ инцидента](analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md) · [USAGE](docs/USAGE.md#управление-валидаторами) |
| 2026-10-02 | Самопроверка через ручку control-api (`self_check.methods`, способ `control_api` рядом с IP-echo); отвязка Floating IP при провале self-check | [план](docs/changes/2026-10-02_03-06_self-check-control-api-plan.md) · [API](docs/API.md#get-apiv1agentsidobserved-ip) |
| 2026-10-01 | Скан Floating IP при тысячах адресов: фоновый постраничный скан с прогрессом, фаза `scanning` в автоцикле, постраничные `/ips` и `/registry`, «Обзор» на счётчиках | [план](docs/changes/2026-10-01_18-19_fip-scan-at-scale-plan.md) · [ревью и тесты](docs/changes/2026-10-01_18-59_fip-scan-at-scale-review.md) · [USAGE](docs/USAGE.md#сканирование-floating-ip-из-openstack) · [API](docs/API.md#post-apiv1adminipsscan) |
| 2026-10-01 | Аутентификация: токены администратора и агентов для API, логин и пароль для дашборда | [план](docs/changes/2026-10-01_11-12_authentication-plan.md) · [ревью и тесты](docs/changes/2026-10-01_11-31_authentication-review.md) · [API](docs/API.md#аутентификация) |
| 2026-10-01 | Автоматический цикл проверок по сценарию: очистка → скан FIP → проверка → пауза | [USAGE](docs/USAGE.md#автоматический-цикл-проверок) · [API](docs/API.md#автоматический-цикл-проверок) |
| 2026-09-23 | Сканирование Floating IP и устойчивый реестр адресов с настраиваемой глубиной истории | [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) |
| 2026-09-23 | Поиск по IP и фильтр по статусу на «Обзоре» и «Реестре» | [DASHBOARD](docs/DASHBOARD.md) |
| 2026-09-23 | Пошаговое руководство по развёртыванию в Docker | [SETUP](docs/SETUP.md) |
| 2026-09-18 | Массовая перепроверка в дашборде; compose реального стенда `rxprod-compose` | [DASHBOARD](docs/DASHBOARD.md) |
| 2026-09-13 | Состояние `occupied`: пропуск цикла для уже занятого Floating IP; схемы control/data plane | [DIAGRAMS](docs/DIAGRAMS.md) |
| 2026-08-26 | Управление внешними площадками и heartbeat пробера, новый UI дашборда, переключатель темы | [DASHBOARD](docs/DASHBOARD.md) |
@@ -0,0 +1,167 @@
# Аналитический разбор массовой проверки: 1026 адресов
> Время отчёта: 2026-10-02 08:56 UTC · Данные: снимок БД control-api на 08:46:53 UTC и лог control-api за 4 часа до остановки
> Проверено адресов: **1026** (984 завершены `done` + 42 завершены `failed`) из 6440 в очереди
> Окно прогона: 07:14:58 – 08:46:53 UTC (1 ч 32 мин). Остановлен вручную в 08:53:46 UTC.
## 1. Остановка проверок
- Проверки остановлены в 08:53:46 UTC операцией «Очистить всё» (`POST /api/v1/admin/ips/clear`).
- После остановки: очередь пуста, все 20 валидаторов `idle`, последняя выдача адреса в 08:53:43, новых выдач нет.
Реестр с историей адресов сохранён (6445 записей).
- Отвязка Floating IP с портов валидаторов при очистке: 13 зависших привязок снято прямым опросом портов
(`detached floating ip from validator port`), ещё 8 привязок, завершавшихся во время очистки, система сняла сама
(`address removed during association`). Состояние портов в самом OpenStack на момент отчёта не проверялось.
- **Первая попытка очистки не сработала.** Очистка шла дольше таймаута клиента (120 с) и оборвалась: 600 отвязок завершились
с ошибкой `context canceled`, ничего не удалилось, проверки продолжались. Повторная очистка без таймаута выполнялась 256 с.
- **Причина долгой очистки (дефект кода, не исправлен):** у завершённых адресов (`done`) в БД остаётся `fip_id`, и очистка
последовательно отвязывает все такие FIP, хотя они уже свободны (более 1000 вызовов OpenStack по ~0,2 с).
## 2. Итоги прогона
| Показатель | Значение |
|---|---|
| Адресов в очереди | 6440 |
| Завершено | 1026 (984 `done` + 42 `failed`) |
| `pass` | 227 (23% от завершённых) |
| `partial` | 757 (77%) |
| `fail` | 42 (все `failed`, вердикта по существу нет, см. раздел 4) |
| Остались в очереди / в работе на момент снимка | 5392 в очереди, 22 в работе |
Пропускная способность по 10-минутным окнам (завершено адресов за минуту): 07:10 — 5,8; 07:20 — 13,4; 07:30 — 11,6;
07:40 — 10,1; 07:50 — 10,6; 08:00 — 9,4; 08:10 — 9,7; 08:20 — 10,5; 08:30 — 10,6; 08:40 — 6,7 (окно неполное).
Цикл одного адреса (от выдачи валидатору до итога): минимум 65 с, медиана 85 с, p90 100 с, p99 115 с, максимум 125 с,
среднее 84 с (по 984 адресам `done`). Для 20 валидаторов это теоретически ~14 адресов в минуту; фактически ~10,4,
потеря около 27% из-за залипших валидаторов (раздел 4).
## 3. Фактура по накопившимся ошибкам
### 3.1. Классы ошибок (с 07:10)
В логе control-api за 4 часа на уровне `ERROR` только один вид сообщений: 41 ошибка привязки Floating IP.
| Ошибка / событие | Число | Что это |
|---|---|---|
| `lease expired` (возврат адреса по истечении лизинга) | 198 | Валидатор не подхватил задание за время лизинга. Затронуто минимум 77 адресов (часть событий без привязки к адресу) |
| Привязка FIP: `409 Cannot associate floating IP … fixed IP already has a floating IP` | 41 | На порту валидатора уже висит другой FIP. Порты: v1 — 13, v7 — 6, v13 — 6, v3 — 5, v12 — 5, v16 — 4, v17 — 2 |
| `validator_unreachable` | 7 | По одному разу: v1 и v12 (07:18:03), v13 (07:33:53), v3 (07:34:33), v7 (07:40:03), v16 (07:42:33), v17 (08:17:53) |
| `site_unreachable` | 5 | rxmsk (08:09, 08:45) и misha-v (08:09, 08:20, 08:45) |
| «Очистить всё»: `context canceled` | 600 | Последствие обрыва первой очистки (08:49), см. раздел 1 |
Чего не было: **провалов self-check — 0 из 1015** результатов; все 1015 прошли способом `control_api` (запасной `ip_echo`
не понадобился). Событий `fip_occupied` — 0, адресов `occupied` — 0.
Журнал событий за прогон: `self_check_result` 2030 (по две записи на адрес), `fip_associated` 1120, `config_received` 1015,
`aggregated` 1004, `retry_or_fail` 239 (198 лизинг + 41 привязка), `lease_expired` 198, `validator_unreachable` 7,
`site_unreachable` 5.
### 3.2. По валидаторам
| Валидатор | Завершено (`done`) | `failed` | Сбросов лизинга | `unreachable` |
|---|---|---|---|---|
| vkiplab-v1 | 2 | 17 | 49 | 1 |
| vkiplab-v12 | 2 | 13 | 51 | 1 |
| vkiplab-v13 | 13 | 10 | 41 | 1 |
| vkiplab-v16 | 38 | 2 | 19 | 1 |
| vkiplab-v7 | 36 | 0 | 21 | 1 |
| vkiplab-v17 | 50 | 0 | 10 | 1 |
| vkiplab-v3 | 50 | 0 | 7 | 1 |
| остальные 13 (v2, v4–v6, v8–v11, v14, v15, v18–v20) | 60–63 | 0 | 0 | 0 |
(Сбросы лизинга и `unreachable` в таблице — только за прогон, с 07:10. У v14, v18 и v2 в истории БД есть сбросы лизинга
за 1 октября, к этому прогону они не относятся.)
- **v1 и v12** не подхватили ни одного задания после 07:17 (последний подхват 07:17:29 и 07:17:26).
- **v13** — после 07:33:16.
- **v7, v16, v17, v3** залипали временно и затем восстановились; механизм восстановления не выяснен.
### 3.3. 42 адреса `fail`
- По валидаторам: v1 — 17, v12 — 13, v13 — 10, v16 — 2. Все 42 исчерпали повторы: `retry_count` = 4 и `attempt_number` = 4 у каждого.
- Причины неудачных попыток по этим адресам: 143 сброса лизинга и 25 ошибок привязки `409`.
- **Ни одной проверки по ним не выполнено** (в таблице проверок у этих адресов 0 записей): адреса не получили вердикта,
и `fail` здесь не характеризует сами адреса.
- Появлялись равномерно с 07:31 до 08:46 (4–10 за 10 минут).
- Подсети: 37.139.x, 79.137.x, 83.166.x и другие, без концентрации.
## 4. Причины
Подтверждены по БД и логам control-api. Логи агентов на самих валидаторах не изучались.
**Причина 1. Валидатор получает два адреса сразу и застревает.** Два дефекта вместе:
- Heartbeat (`queries_validators.go`) возвращает валидатор из `unreachable` в `idle`, не проверяя, что за ним числится адрес.
Адрес с долгими внешними проверками (3–4 таймаута по 10 с) блокирует агента больше 30 с (порог
`heartbeat_timeout_seconds`), control-api помечает валидатор недоступным, затем возвращает в `idle` занятым.
- При завершении старого адреса `ReleaseFIP` (`queries_ipqueue.go`) освобождает валидатор по его имени, а не по адресу.
- Подтверждение: **35 двойных выдач** (два адреса одному валидатору с интервалом ~5 с) в логе: v1 — 10, v12 — 9, v7 — 6,
v13 — 4, v17 — 3, v16 — 2, v3 — 1. Из 1265 выдач в логе.
**Причина 2. Регрессия моей правки с параллельной привязкой.** Защита от дублей в `orchestrator.go:149` ключуется по
валидатору (`assign:<validator>`). Вторая выдача того же валидатора пропускает привязку, и адрес стоит в `assigning_fip`
до истечения лизинга. В БД у валидатора одно поле `current_ip_id`, оно указывает на последний выданный адрес, агент по нему
получает пустое задание, лизинг истекает, валидатор берёт новый адрес, и круг повторяется. Ошибки `409` — следствие: на
порту остаётся FIP первого адреса, привязать второй нельзя.
**Причина 3. Агент молчит во время долгих проверок.** Heartbeat отправляется только между заданиями; адреса с несколькими
таймаутами ведут к `unreachable` (пусковой механизм причины 1). Пример: v1 проверял `37.139.32.1`, v12 — `37.139.32.4`;
у обоих проваливались все четыре внешних HTTPS-цели (~40 с таймаутов), в 07:18:03 оба помечены `unreachable`.
**Дефект очистки.** См. раздел 1: отвязка всех `done`-адресов последовательно.
## 5. Результаты проверок самих адресов
**Почему 77% `partial`:** 735 адресов проваливают только исходящие HTTPS, ещё 22 — исходящие и входящие.
| Цель (egress HTTPS) | Адресов с провалом (из 984) |
|---|---|
| `packages.ubuntu.com` | 698 (71%) |
| `repo.almalinux.org/almalinux/` | 304 (31%) |
| `github.com` | 251 (26%) |
| `hub.docker.com` | 17 (2%) |
Число провалов на один `partial`-адрес: 1 — 392 адреса, 2 — 202, 3 — 136, 4 и больше — 27.
По подсетям (доля адресов с провалом цели):
| Подсеть | Адресов | `packages.ubuntu.com` | `repo.almalinux.org` | `github.com` | `hub.docker.com` | Доля `partial` |
|---|---|---|---|---|---|---|
| 83.166.x | 435 | 80% | 53% | 42% | 0% | 92% |
| 37.139.x | 418 | 62% | 11% | 10% | 4% | 63% |
| 79.137.x | 109 | 66% | 19% | 18% | 0% | 68% |
| 5.188.x | 22 | 59% | 0% | 0% | 0% | 59% |
- `packages.ubuntu.com` проваливается у всех подсетей и во все окна. Доля проваленных строк egress для этой цели по 10-минутным
окнам выросла с 26% до 40% (строки включают HTTPS и ICMP, поэтому реальная доля HTTPS вдвое выше).
- Доля `partial` почти одинакова у всех валидаторов (72–84%; v1 — 100% по 2 адресам, v12 — 50% по 2): причина в самом адресе
или во внешнем ресурсе, а не в валидаторе.
- Провалы `repo.almalinux.org` и `github.com` сильно зависят от подсети (83.166.x — 53% и 42%, 37.139.x — 11% и 10%).
- Успешные HTTPS-проверки: медиана 200 мс, p90 6707 мс (много ответов близко к таймауту 10 с).
**Входящие проверки.** Провалы только у `inbound-site-1` (33 пробы из 2934) и `inbound-site-3` (50 из 2931); `inbound-site-2` — 0 из 2952,
`inbound-site-4` — 1 из 2952. Провалы всплесками в 08:00, 08:20 и 08:40 по ICMP, SSH и TCP 22; у 20 адресов упали все пробы
одной площадки. Пробер rxmsk и misha-v в эти же минуты отмечены `unreachable`. Причина недоступности проберов не выяснена.
## 6. Рекомендации
1. **Исправить оркестратор** (до повторного запуска): защита от дублей по адресу, `unreachable` → `assigned` вместо `idle`,
освобождение валидатора только по текущему адресу, heartbeat агента в отдельном потоке.
2. **Исправить очистку:** отвязывать только действительно привязанные FIP (без `fip_released_at`) и параллельно.
3. **Перепроверить 42 адреса** после исправления: вердикта по существу они не получили. Все `fail` с причиной «lease expired» считать недействительными.
4. **Решить по `packages.ubuntu.com`:** 71% `partial` и 10 с таймаута на каждый такой адрес; если ресурс нестабилен, убрать или заменить.
5. **Проверить причины `site_unreachable`** у проберов (возможна перегрузка при большой очереди).
6. Проверить в OpenStack, что порты валидаторов свободны от Floating IP.
## 7. Что не проверено
- Состояние портов и Floating IP в самом OpenStack.
- Логи агентов `validator-agent` на валидаторах и логи проберов (причины блокировок heartbeat подтверждены по времени и
событиям control-api, не по логам агентов).
- Почему залипшие v7, v16, v17, v3 восстановились.
- Причины провалов внешних HTTPS-целей (ресурс, сеть облака или фильтрация) и недоступности проберов.
## 8. Источники данных
- Снимок БД control-api: `.backup` на 08:46:53 UTC (таблицы `ip_queue`, `checks`, `events`, `validators`, `sites`).
- Лог контейнера control-api за 4 часа до остановки (1504 строки) и за период очистки.
- Статус и результат очистки: HTTP 200 за 256,7 с.
@@ -0,0 +1,42 @@
37.139.32.70
37.139.32.71
37.139.32.72
37.139.32.151
37.139.33.219
37.139.33.227
37.139.33.230
37.139.34.75
37.139.34.94
37.139.34.128
37.139.40.122
37.139.41.103
37.139.41.129
37.139.41.157
37.139.41.202
37.139.42.126
37.139.43.9
79.137.174.150
79.137.174.172
79.137.174.205
79.137.174.210
79.137.175.14
79.137.175.51
79.137.175.71
79.137.175.162
83.166.232.116
83.166.232.117
83.166.232.159
83.166.233.37
83.166.233.75
83.166.233.78
83.166.234.136
83.166.235.75
83.166.235.132
83.166.235.247
83.166.237.59
83.166.237.228
83.166.238.19
83.166.248.57
83.166.248.60
83.166.248.92
83.166.248.188
+4 -4
View File
@@ -1,4 +1,4 @@
194bbd250ad93142eb1eb2d8990292327d48842f851f522ddb31f9ddfa8c554f control-api
43ca6b15a25e8534ae2d833e8d0575a85da036e17db370c797da613bb51a9c29 validator-agent
bde443dd4fd335f9c3c64a2ca04d5db6c9a93fe2f45eb81b1d426581e380cee2 prober
e74de9873e9da1ebfa6beeccd34b6fa7c15bf14c2d8efe13f4de7266af141702 admin-dashboard
9447699d9eed4b9fb5d417769fc868986a10357ec633ec3742883b3002aaafc3 control-api
9fb6608b84143f7c4f318f3cc92dcd9f95c7831d627b23d67cce5a5908ced704 validator-agent
3e9e14dbb361ee76aaad7c1da6864b3ea111e0ed151403f904b12485631bbf75 prober
d72234688eb1954dbff420ca1c8e83b83ceab561b18a0336af0f73fe4d9ac8be admin-dashboard
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+10
View File
@@ -39,11 +39,21 @@ func run(configPath string, log *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
controlAPIToken := os.Getenv(cfg.ControlAPI.TokenEnv)
if controlAPIToken == "" {
log.Warn("control-api admin token is not set: calls to control-api are sent without credentials", "env", cfg.ControlAPI.TokenEnv)
}
srv, err := dashboard.New(dashboard.Config{
ControlAPIBaseURL: cfg.ControlAPI.BaseURL,
ControlAPITimeout: time.Duration(cfg.ControlAPI.TimeoutSeconds) * time.Second,
LastCompletedCount: cfg.Overview.LastCompletedCount,
OverviewPollIntervalS: cfg.Overview.PollIntervalSeconds,
ControlAPIToken: controlAPIToken,
Username: os.Getenv(cfg.Auth.UsernameEnv),
Password: os.Getenv(cfg.Auth.PasswordEnv),
SessionSecret: os.Getenv(cfg.Auth.SessionSecretEnv),
SessionTTL: time.Duration(cfg.Auth.SessionTTLMinutes) * time.Minute,
}, log)
if err != nil {
return fmt.Errorf("init dashboard: %w", err)
+34 -3
View File
@@ -59,8 +59,21 @@ func run(configPath string, log *slog.Logger) error {
}
orch := orchestrator.New(database, osClient, cfg, log)
orch.Async = true // slow OpenStack calls run per address, Tick never waits for them
// Background jobs (the floating-IP scan) live as long as the process, not
// as long as the HTTP request or loop iteration that started them.
orch.SetContext(ctx)
srv := httpapi.New(database, orch, log)
adminToken := os.Getenv(cfg.Auth.AdminTokenEnv)
agentToken := os.Getenv(cfg.Auth.AgentTokenEnv)
if adminToken == "" {
log.Warn("admin API is open: admin token is not set", "env", cfg.Auth.AdminTokenEnv)
}
if agentToken == "" {
log.Warn("agent write API is open: agent token is not set", "env", cfg.Auth.AgentTokenEnv)
}
srv := httpapi.New(database, orch, log).WithAuth(adminToken, agentToken)
httpServer := &http.Server{Addr: cfg.Server.ListenAddr, Handler: srv.Handler()}
go runOrchestratorLoop(ctx, orch, cfg, log)
@@ -109,6 +122,7 @@ func runOrchestratorLoop(ctx context.Context, orch *orchestrator.Orchestrator, c
return
case <-ticker.C:
orch.Tick(ctx)
orch.AutoCycleStep(ctx)
case <-heartbeatTicker.C:
if err := orch.SweepStaleHeartbeats(ctx); err != nil {
log.Error("sweep stale heartbeats", "err", err)
@@ -117,8 +131,18 @@ func runOrchestratorLoop(ctx context.Context, orch *orchestrator.Orchestrator, c
log.Error("sweep stale site heartbeats", "err", err)
}
case <-scanTickerC:
if _, _, err := orch.ScanFloatingIPs(ctx); err != nil {
log.Error("scan floating ips", "err", err)
// The auto-cycle owns the queue while enabled: a periodic scan
// would add addresses in the middle of a cycle.
ac, err := orch.GetAutoCycle(ctx)
if err != nil {
log.Error("read auto-cycle before periodic scan, scanning anyway", "err", err)
} else if ac.Enabled {
continue
}
// Non-blocking: the scan runs in the background (single-flight, so
// a still-running scan is simply joined) and must not stall Tick.
if st, started := orch.StartScan(orchestrator.ScanOptions{}); !started {
log.Info("periodic floating ip scan skipped: a scan is already running", "state", st.State)
}
}
}
@@ -140,11 +164,18 @@ func newOpenStackClient(ctx context.Context, cfg *config.ControlAPI) (openstack.
}
func newRealOpenStackClient(ctx context.Context, cfg *config.ControlAPI) (openstack.FloatingIPClient, error) {
retries := cfg.OpenStack.ListPageRetries
if retries < 0 {
retries = 0 // negative in the config disables retries
}
clientCfg := openstack.ClientConfig{
AuthURL: os.Getenv(cfg.OpenStack.AuthURLEnv),
ProjectID: os.Getenv(cfg.OpenStack.ProjectIDEnv),
Region: os.Getenv(cfg.OpenStack.RegionEnv),
Interface: os.Getenv(cfg.OpenStack.InterfaceEnv),
RequestTimeout: time.Duration(cfg.OpenStack.RequestTimeoutSeconds) * time.Second,
ListPageRetries: retries,
}
switch cfg.OpenStack.AuthMethod {
+5 -1
View File
@@ -31,7 +31,11 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
prober := probercore.New(cfg, log)
token := os.Getenv(cfg.ControlAPITokenEnv)
if token == "" {
log.Warn("agent token is not set: result calls will be rejected by an authenticated control-api", "env", cfg.ControlAPITokenEnv)
}
prober := probercore.New(cfg, log).WithToken(token)
if err := prober.Run(ctx); err != nil && err != context.Canceled {
log.Error("prober stopped", "err", err)
os.Exit(1)
+5 -1
View File
@@ -39,7 +39,11 @@ func main() {
startStubListeners(ctx, log, *stubPorts)
}
agent := agentcore.New(cfg, log)
token := os.Getenv(cfg.ControlAPITokenEnv)
if token == "" {
log.Warn("agent token is not set: result/event/complete calls will be rejected by an authenticated control-api", "env", cfg.ControlAPITokenEnv)
}
agent := agentcore.New(cfg, log).WithToken(token)
if err := agent.Run(ctx); err != nil && err != context.Canceled {
log.Error("agent stopped", "err", err)
os.Exit(1)
+17
View File
@@ -4,6 +4,23 @@ server:
control_api:
base_url: "http://control-api.internal:8080"
timeout_seconds: 10
# Имя переменной окружения с admin-токеном control-api
# (CONTROL_API_ADMIN_TOKEN на стороне control-api). Пусто в окружении —
# запросы идут без заголовка Authorization.
token_env: "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
# Вход в дашборд: один администратор, сессия в cookie. Здесь только ИМЕНА
# переменных окружения; значения задаются окружением процесса (см.
# deploy/systemd/admin-dashboard.service). Если логин или пароль не заданы —
# вход не требуется (в логе предупреждение при старте).
auth:
username_env: "ADMIN_DASHBOARD_USERNAME"
password_env: "ADMIN_DASHBOARD_PASSWORD"
# Ключ подписи cookie сессии (openssl rand -hex 32). Не задан — ключ
# случайный на каждый старт, и рестарт сбрасывает все сессии.
session_secret_env: "ADMIN_DASHBOARD_SESSION_SECRET"
# Срок жизни сессии, минут (по умолчанию 480 = 8 часов).
session_ttl_minutes: 480
# Настройки сводки на странице "Обзор" — см. docs/DASHBOARD.md. Оба поля
# влияют только на то, как дашборд группирует уже существующие данные
+34
View File
@@ -37,6 +37,36 @@ openstack:
user_domain_name_env: "OS_USER_DOMAIN_NAME"
password_env: "OS_PASSWORD"
# Постраничное чтение Floating IP (скан при тысячах адресов): сколько
# адресов запрашивать у Neutron за один запрос. Default 200.
# Page size of the paged floating-IP listing. Default 200.
list_page_size: 200
# Таймаут каждого HTTP-запроса к Keystone/Neutron, секунд. Default 60.
# Per-request HTTP timeout (also protects the orchestrator tick from a
# hung Neutron call). Default 60.
request_timeout_seconds: 60
# Сколько раз повторять неудавшуюся страницу (сетевая ошибка, EOF/
# RemoteDisconnected, 5xx, 429) с паузами 1,2,4,8,16 с. Default 5;
# отрицательное значение отключает повторы.
# Retries per failed listing page. Default 5; negative disables retries.
list_page_retries: 5
# Аутентификация API: здесь только ИМЕНА переменных окружения, значения
# (статические bearer-токены) задаются окружением процесса — см.
# deploy/systemd/control-api.service (EnvironmentFile=). Генерация:
# openssl rand -hex 32
# Пустой/незаданный токен оставляет соответствующий уровень ОТКРЫТЫМ
# (в логе при старте предупреждение) — для обратной совместимости.
auth:
# Защищает все /api/v1/admin/* (его использует дашборд и оператор: curl -H
# "Authorization: Bearer $TOKEN").
admin_token_env: "CONTROL_API_ADMIN_TOKEN"
# Защищает запись результатов/событий: POST /agents/{id}/self-check|events|
# results|complete и POST /probers/{site_id}/results. Один общий токен для
# validator-agent и prober. register/heartbeat/получение задания остаются
# открытыми.
agent_token_env: "CONTROL_API_AGENT_TOKEN"
orchestrator:
poll_interval_seconds: 5
self_check_timeout_seconds: 60
@@ -60,6 +90,10 @@ orchestrator:
# scan on demand via POST /api/v1/admin/ips/scan or the dashboard's
# "Scan Floating IPs" button.
fip_scan_interval_seconds: 0
# Общий таймаут одного фонового скана Floating IP (очистка + чтение всех
# страниц + постановка в очередь), секунд. Default 1800.
# Overall deadline of one background floating-IP scan. Default 1800.
fip_scan_timeout_seconds: 1800
aggregation:
missing_counts_as_fail: true
+4
View File
@@ -3,6 +3,10 @@
site_id: "site-1"
control_api_url: "http://control-api.internal:8080"
# Имя переменной окружения с токеном агентов control-api (тот же, что у
# validator-agent; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для
# записи результатов; register/heartbeat/получение заданий работают без него.
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
poll_interval_seconds: 5
checks:
+19 -1
View File
@@ -3,11 +3,29 @@
validator_id: "validator_01"
control_api_url: "http://control-api.internal:8080"
# Имя переменной окружения с токеном агентов control-api (тот же, что у
# prober; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для записи
# результатов/событий; register/heartbeat/получение задания работают без него.
# Токен отправляется только в control-api — не на ip_echo_urls и не на цели
# проверок.
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
poll_interval_seconds: 5
self_check:
timeout_seconds: 10
# Must be a resource genuinely outside the cloud project — OpenStack only
# Способы самопроверки в порядке приоритета (допустимо: ip_echo,
# control_api); по умолчанию [ip_echo]. Самопроверка успешна, если адрес
# подтвердил любой способ: пробуются по порядку, остановка на первом
# успешном, к следующему переходим и при отсутствии ответа, и при
# несовпадении адреса. Таймаут timeout_seconds действует на каждый способ
# отдельно (зависший первый способ не лишает второй времени). control_api спрашивает у control-api, с какого адреса он видит
# это соединение; рекомендуется [control_api, ip_echo], когда control-api
# стоит вне облака и валидатор ходит к нему напрямую (через внешнюю сеть).
# Ограничение: если control-api достижим по внутренней сети облака, он
# увидит частный адрес валидатора и control_api всегда даст несовпадение —
# тогда оставьте только ip_echo (или он сработает вторым в списке).
methods: [control_api, ip_echo]
# Used by the ip_echo method. Must be a resource genuinely outside the cloud project — OpenStack only
# applies floating-IP SNAT to traffic leaving via the external network,
# so anything reachable over the project's internal network (including
# control-api itself, if it's on the same internal network) would report
+110
View File
@@ -0,0 +1,110 @@
# Доставка validator-agent на валидаторы (Ansible)
Сценарий запускается с jump-хоста и на каждой ВМ-валидаторе: обновляет git-клон в `/opt/cloud-ip-validator`,
**собирает образ на самом хосте**, останавливает и удаляет старый контейнер `validator-agent` (образ — `cloud-ip-validator-validator-agent`)
и поднимает на его месте новый. Параметры запуска агента вынесены в env-файл.
Порядок безопасен: сначала проверки, обновление кода и сборка образа, и только потом замена контейнера. Если что-то
упало до замены, старый контейнер продолжает работать. Простой валидатора — секунды (`stop` + `rm` + `run`).
## Требования
| Где | Что |
|---|---|
| jump-хост (Debian 13) | `apt install ansible-core`; SSH-доступ по ключу ко всем валидаторам |
| валидаторы | Docker, git, клон репозитория в `/opt/cloud-ip-validator`, `sudo` без пароля для SSH-пользователя, доступ к Gitea и Docker Hub (`alpine:3.20`), архитектура x86_64 |
Только модули `ansible.builtin`: Python Docker SDK и дополнительные коллекции на валидаторах не нужны.
Go на валидаторах не нужен: образ копирует закоммиченный `bin/validator-agent` (его сумма проверяется по `bin/SHA256SUMS`).
## Подготовка (один раз)
```bash
cd deploy/ansible
cp env/validator-agent.env.example env/validator-agent.env
chmod 600 env/validator-agent.env
$EDITOR env/validator-agent.env # адрес control-api, токен, способы самопроверки, таймауты
ansible validators -m ping # проверка связи (20 хостов: validator-1 ... validator-20)
```
- В `inventory/hosts.yml` 20 хостов `validator-1 … validator-20` с адресами (`ansible_host`) и переменной `validator_id`
(`vkiplab-v1 … vkiplab-v20`, как в control-api). Соответствие `validator-N` → `vkiplab-vN` задано по порядку номеров;
**сценарий сверяет `validator_id` с тем, что записано в работающем контейнере на хосте, и при расхождении останавливается до замены
контейнера**. `VALIDATOR_AGENT_VALIDATOR_ID` в env-файл писать не нужно: сценарий подставляет его из inventory.
- Отпечатки хостов запоминаются при первом подключении (`StrictHostKeyChecking=accept-new` в `ansible.cfg`); сменившийся отпечаток известного хоста — ошибка.
- SSH: пользователь `debian` и ключ `~/.ssh/vk_cloud_priv.key` на jump-хосте (права 0600) — одинаковые на всех хостах; меняются в
`inventory/group_vars/validators.yml` (`ansible_user`, `ansible_ssh_private_key_file`). Для docker и записи env-файла сценарий
повышает права через `sudo`.
- Токен в env-файле можно зашифровать: `ansible-vault encrypt env/validator-agent.env`, запускать с `--ask-vault-pass`.
Рабочий `env/validator-agent.env` в git не попадает (`.gitignore`).
## Запуск
```bash
cd deploy/ansible
ansible-playbook playbooks/deploy-validator-agent.yml --limit vkiplab-v1 # канарейка: один валидатор
ansible-playbook playbooks/deploy-validator-agent.yml # все валидаторы волнами
ansible-playbook playbooks/deploy-validator-agent.yml --check # только проверки (preflight), без изменений
```
Волны по умолчанию: 1 хост, затем 4, затем все остальные (`deploy_serial: [1, 4, "100%"]`). Любой сбой в волне останавливает
прогон: следующая волна не начнётся. Все сразу: `-e '{"deploy_serial": ["100%"]}'`.
Что выкатывается — `deploy_ref` (по умолчанию `main`): ветка, тег или коммит. Выкатить нужно **запушенный** коммит:
валидаторы берут код из репозитория, а не с jump-хоста. После правок кода сначала пересоберите `bin/validator-agent`
(см. [SETUP.md](../../docs/SETUP.md#обновление-образов-после-изменения-кода)) и закоммитьте его вместе с `bin/SHA256SUMS`.
### Откат
```bash
ansible-playbook playbooks/deploy-validator-agent.yml -e deploy_ref=<предыдущий коммит или тег>
```
Для тега или коммита клон переходит в detached HEAD; следующий запуск с `deploy_ref=main` возвращает его на ветку.
## Что делает сценарий на каждом хосте
1. **preflight** — env-файл на jump-хосте существует и в нём задан `VALIDATOR_AGENT_CONTROL_API_URL` (адрес-пример
`example.com` не принимается); на валидаторе отвечает Docker, есть git и клон, архитектура x86_64; `validator_id` из inventory совпадает
с `validator_id` работающего контейнера; на хосте нет другого контейнера этого агента (по имени или образу) — иначе рядом со старым
запустился бы второй с тем же `validator_id`. Показывает текущий контейнер.
2. **git** — от имени `git_user` (на валидаторах `root`: клон принадлежит ему) `fetch`, затем клон сбрасывается на `deploy_ref` (`checkout --force`). Через `git`, а не модуль `git`:
учётные данные, уже настроенные в клоне, не трогаются. Локальные правки отслеживаемых файлов в клоне будут сброшены;
неотслеживаемые и игнорируемые (`.env.*`) — нет.
3. **build** — проверка `bin/validator-agent` по `bin/SHA256SUMS`; `docker build --platform linux/amd64` с контекстом в корне репозитория,
образ получает метку ревизии (`cloud-ip-validator-validator-agent:<хеш>`) и `latest`.
4. **replace** — env-файл копируется в `/opt/cloud-ip-validator/deploy/docker/.env.validator` (0600; путь закрыт `.gitignore`),
затем `docker stop` → `docker rm -f` → `docker run -d --restart unless-stopped --cap-add NET_RAW --env-file …`.
5. **verify** — ждёт строку `registered` в логе агента (регистрация в control-api), проверяет, что контейнер запущен, без перезапусков
и на только что собранном образе. При неудаче хост падает с последними строками лога, следующие волны не стартуют.
Затем остаются 3 последних образа с метками ревизий (`keep_images`), остальные и «висячие» удаляются.
Все шаги можно запускать по тегам: `--tags preflight|git|build|replace|verify`.
## Параметры
**`env/validator-agent.env`** — запуск агента (читает `deploy/docker/validator-agent/docker-entrypoint.sh`):
`VALIDATOR_AGENT_CONTROL_API_URL`, `CONTROL_API_AGENT_TOKEN`, `VALIDATOR_AGENT_SELF_CHECK_METHODS`,
`VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS`, `VALIDATOR_AGENT_POLL_INTERVAL_SECONDS`, `VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS`,
`VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS`, `VALIDATOR_AGENT_ICMP_COUNT`, `VALIDATOR_AGENT_SSH_ENABLED`, `VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS`.
Смена только env-файла не требует изменения кода: достаточно запустить сценарий (контейнер пересоздаётся с новыми значениями).
**`inventory/group_vars/validators.yml`** — доставка: `repo_dir`, `repo_remote`, `deploy_ref`, `git_user` (пользователь, у которого в клоне
настроен доступ к репозиторию; пусто — SSH-пользователь), `image_name`, `container_name`, `platform`, `keep_images`, `restart_policy`,
`capabilities`, `log_max_size`, `log_max_file`, `stop_timeout`, `verify_retries`, `verify_delay`, `local_env_file`. Любой параметр
переопределяется ключом `-e`.
## Разбор сбоев
- **«Нет env-файла» / «не задан VALIDATOR_AGENT_CONTROL_API_URL»** — см. «Подготовка».
- **«в запущенном контейнере validator_id=…, а в inventory …»** — соответствие `validator-N` и `validator_id` в `inventory/hosts.yml`
неверно для этого хоста: исправьте inventory (контейнер при этом не тронут).
- **Контейнер не прошёл проверку** — в сообщении последние строки лога. `registration failed` — control-api недоступен с валидатора
или `validator_id` не заведён в control-api. Контейнер остаётся на хосте для разбора (`docker logs`).
- **`Permission denied` на `.git/FETCH_HEAD`, `dubious ownership`, `could not read Username`** — git запущен не от владельца клона или
учётные данные есть у другого пользователя: задайте `git_user` (на валидаторах — `root`).
- **«найден другой контейнер агента»** — на хосте есть контейнер с похожим именем или образом, не совпадающий с `container_name`:
проверьте имя (`docker ps -a`) и при необходимости удалите лишний контейнер вручную.
- **`bin/validator-agent` не совпадает с суммой** — в репозитории устарел `bin/SHA256SUMS`: пересоберите бинарник и обновите сумму.
- **Сборка падает на `FROM alpine:3.20` / `apk add`** — с валидатора нет доступа к Docker Hub / репозиториям Alpine.
- **Старый образ другого имени остаётся** — сценарий чистит только образы `image_name`; образ с прежним именем удалите вручную (`docker rmi`).
+15
View File
@@ -0,0 +1,15 @@
# Запускать из каталога deploy/ansible (там же лежит этот файл).
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
forks = 20
retry_files_enabled = False
interpreter_python = auto_silent
callback_result_format = yaml
[ssh_connection]
pipelining = True
# accept-new: отпечаток нового хоста запоминается при первом подключении (без
# интерактивного вопроса); изменившийся отпечаток известного хоста по-прежнему
# приводит к ошибке.
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new
+29
View File
@@ -0,0 +1,29 @@
# Параметры запуска validator-agent. Скопируйте в validator-agent.env и заполните:
# cp validator-agent.env.example validator-agent.env && chmod 600 validator-agent.env
# Файл передаётся контейнеру как `docker run --env-file` (формат KEY=VALUE, без
# кавычек и пробелов вокруг "="). Читает их deploy/docker/validator-agent/docker-entrypoint.sh.
# VALIDATOR_AGENT_VALIDATOR_ID сюда НЕ пишется: сценарий подставляет имя хоста.
# Адрес control-api (обязательно). Для внешнего размещения — адрес, доступный
# с валидаторов напрямую (через него же работает способ самопроверки control_api).
VALIDATOR_AGENT_CONTROL_API_URL=https://control-api.example.com
# Токен агентов (CONTROL_API_AGENT_TOKEN на стороне control-api). Пусто — если
# токен на control-api ещё не включён.
CONTROL_API_AGENT_TOKEN=
# Способы самопроверки в порядке приоритета: ip_echo, control_api.
# Самопроверка проходит, если адрес подтвердил любой способ. Без пробелов.
VALIDATOR_AGENT_SELF_CHECK_METHODS=[control_api,ip_echo]
# Таймаут одного способа, секунд.
VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS=10
# Период опроса control-api, секунд.
VALIDATOR_AGENT_POLL_INTERVAL_SECONDS=5
# Исходящие проверки.
VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS=10
VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS=5
VALIDATOR_AGENT_ICMP_COUNT=3
VALIDATOR_AGENT_SSH_ENABLED=false
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5
@@ -0,0 +1,50 @@
---
# Параметры доставки validator-agent (не секреты). Любой из них можно
# переопределить в командной строке: -e deploy_ref=<коммит|тег>.
# Параметры запуска самого агента (адрес control-api, токен, способы
# самопроверки, таймауты) лежат в env-файле, см. local_env_file.
# SSH: пользователь и ключ одинаковы на jump-хосте и на валидаторах. Путь к
# ключу — на jump-хосте (ключ с правами 0600). Для docker и записи env-файла
# сценарий повышает права через sudo (become).
ansible_user: debian
ansible_ssh_private_key_file: ~/.ssh/vk_cloud_priv.key
# --- git-клон на валидаторе ---------------------------------------------
repo_dir: /opt/cloud-ip-validator
repo_remote: origin
# Ветка, тег или коммит, который нужно выкатить (откат: -e deploy_ref=<коммит>).
deploy_ref: main
# Пользователь, от имени которого выполняется git в клоне (через sudo): владелец
# клона. На валидаторах клон принадлежит root, репозиторий читается без учётных
# данных. Пусто — git работает от SSH-пользователя без sudo.
git_user: root
# --- образ и контейнер --------------------------------------------------
image_name: cloud-ip-validator-validator-agent
# Имя контейнера на валидаторах (имя образа — другое, см. image_name).
container_name: validator-agent
platform: linux/amd64
dockerfile: deploy/docker/validator-agent/Dockerfile
# Сколько образов с метками ревизий хранить (кроме latest); старые удаляются.
keep_images: 3
# --- запуск контейнера (как в deploy/docker/RUN.txt) --------------------
restart_policy: unless-stopped
capabilities: [NET_RAW]
log_max_size: 10m
log_max_file: "3"
stop_timeout: 10
# --- проверка после запуска ---------------------------------------------
verify_retries: 10
verify_delay: 3
# --- env-файл на jump-хосте ---------------------------------------------
# Параметры запуска агента. Рабочий файл создаётся из validator-agent.env.example
# и в git не попадает. Файл можно зашифровать: ansible-vault encrypt <файл>
# (тогда запускайте с --ask-vault-pass или --vault-password-file).
local_env_file: "{{ playbook_dir }}/../env/validator-agent.env"
# Куда файл копируется на валидатор (путь закрыт .gitignore репозитория,
# переживает git reset).
remote_env_file: "{{ repo_dir }}/deploy/docker/.env.validator"
+69
View File
@@ -0,0 +1,69 @@
# Валидаторы. Имя хоста (validator-N) — это имя ВМ; validator_id в control-api
# другой (vkiplab-vN) и задаётся переменной validator_id. Соответствие
# validator-N -> vkiplab-vN предполагается по порядку номеров; сценарий
# сверяет validator_id с тем, что записано в работающем контейнере на хосте,
# и при расхождении останавливается ДО замены контейнера.
all:
children:
validators:
hosts:
validator-1:
ansible_host: 10.11.12.161
validator_id: vkiplab-v1
validator-2:
ansible_host: 10.11.12.177
validator_id: vkiplab-v2
validator-3:
ansible_host: 10.11.12.33
validator_id: vkiplab-v3
validator-4:
ansible_host: 10.11.12.41
validator_id: vkiplab-v4
validator-5:
ansible_host: 10.11.12.193
validator_id: vkiplab-v5
validator-6:
ansible_host: 10.11.12.197
validator_id: vkiplab-v6
validator-7:
ansible_host: 10.11.12.198
validator_id: vkiplab-v7
validator-8:
ansible_host: 10.11.12.169
validator_id: vkiplab-v8
validator-9:
ansible_host: 10.11.12.185
validator_id: vkiplab-v9
validator-10:
ansible_host: 10.11.12.186
validator_id: vkiplab-v10
validator-11:
ansible_host: 10.11.12.199
validator_id: vkiplab-v11
validator-12:
ansible_host: 10.11.12.196
validator_id: vkiplab-v12
validator-13:
ansible_host: 10.11.12.194
validator_id: vkiplab-v13
validator-14:
ansible_host: 10.11.12.195
validator_id: vkiplab-v14
validator-15:
ansible_host: 10.11.12.65
validator_id: vkiplab-v15
validator-16:
ansible_host: 10.11.12.189
validator_id: vkiplab-v16
validator-17:
ansible_host: 10.11.12.190
validator_id: vkiplab-v17
validator-18:
ansible_host: 10.11.12.168
validator_id: vkiplab-v18
validator-19:
ansible_host: 10.11.12.191
validator_id: vkiplab-v19
validator-20:
ansible_host: 10.11.12.73
validator_id: vkiplab-v20
@@ -0,0 +1,17 @@
---
# Доставка validator-agent на все валидаторы: обновить git-клон, собрать образ
# на каждом хосте, остановить и удалить старый контейнер, поднять новый.
# Запуск (из deploy/ansible): ansible-playbook playbooks/deploy-validator-agent.yml
- name: Deliver validator-agent to the validators
hosts: validators
become: true
gather_facts: false
# Волны: один хост (канарейка), затем четыре, затем все остальные. Любой
# сбой останавливает прогон — следующая волна не начнётся.
# Все сразу: -e '{"deploy_serial": ["100%"]}'.
serial: "{{ deploy_serial }}"
max_fail_percentage: 0
vars:
deploy_serial: [1, 4, "100%"]
roles:
- validator_agent
@@ -0,0 +1,40 @@
---
# Dockerfile ничего не компилирует: в образ копируется закоммиченный
# bin/validator-agent. Не даём выкатить бинарник, не совпадающий с суммой.
- name: Check bin/validator-agent against SHA256SUMS
ansible.builtin.shell: |
set -o pipefail
grep -E '[[:space:]]validator-agent$' SHA256SUMS | sha256sum -c -
args:
chdir: "{{ repo_dir }}/bin"
executable: /bin/bash
changed_when: false
# Контекст сборки — корень репозитория (так и в SETUP.md). Слои кэшируются,
# при смене bin/ образ пересобирается сам.
- name: Build the image
ansible.builtin.command:
argv:
- docker
- build
- --platform
- "{{ platform }}"
- --label
- "git.rev={{ rev_after.stdout }}"
- --label
- deployed.by=ansible
- -t
- "{{ image_ref }}"
- -f
- "{{ dockerfile }}"
- .
chdir: "{{ repo_dir }}"
- name: Tag the image as latest
ansible.builtin.command: "docker tag {{ image_ref }} {{ image_name }}:latest"
- name: Read the image id
ansible.builtin.command:
argv: [docker, image, inspect, --format, "{% raw %}{{.Id}}{% endraw %}", "{{ image_ref }}"]
changed_when: false
register: built_image
@@ -0,0 +1,80 @@
---
# Команды git вместо модуля git: модуль переписывает URL remote и может
# затереть учётные данные, уже настроенные в клоне. Работаем от git_user
# (или от SSH-пользователя, если он не задан).
- name: Remember the current revision
ansible.builtin.command: "{{ git_cmd }} rev-parse HEAD"
become: "{{ git_user | length > 0 }}"
become_user: "{{ git_user }}"
changed_when: false
register: rev_before
- name: Fetch the remote
ansible.builtin.command: "{{ git_cmd }} fetch --prune --tags {{ repo_remote }}"
become: "{{ git_user | length > 0 }}"
become_user: "{{ git_user }}"
changed_when: false
# deploy_ref — ветка, тег или коммит. Ветка берётся из remote (свежая),
# тег и коммит — как есть.
- name: Resolve deploy_ref as a remote branch
ansible.builtin.command: >-
{{ git_cmd }} rev-parse --verify --quiet
refs/remotes/{{ repo_remote }}/{{ deploy_ref }}^{commit}
become: "{{ git_user | length > 0 }}"
become_user: "{{ git_user }}"
changed_when: false
failed_when: false
register: ref_branch
- name: Resolve deploy_ref as a tag or commit
ansible.builtin.command: "{{ git_cmd }} rev-parse --verify --quiet {{ deploy_ref }}^{commit}"
become: "{{ git_user | length > 0 }}"
become_user: "{{ git_user }}"
changed_when: false
failed_when: false
register: ref_other
when: ref_branch.rc != 0
- name: Fail if deploy_ref does not exist
ansible.builtin.assert:
that: ref_branch.rc == 0 or (ref_other.rc | default(1)) == 0
fail_msg: "deploy_ref={{ deploy_ref }} не найден в {{ repo_dir }} ({{ repo_remote }})."
quiet: true
- name: Fix the target revision
ansible.builtin.set_fact:
target_rev: "{{ ref_branch.stdout if ref_branch.rc == 0 else ref_other.stdout }}"
# Ветка: остаёмся на локальной ветке (клон не уходит в detached HEAD),
# сброс на remote. Тег или коммит: detached HEAD.
- name: Check out the branch
ansible.builtin.command: "{{ git_cmd }} checkout --force -B {{ deploy_ref }} {{ target_rev }}"
become: "{{ git_user | length > 0 }}"
become_user: "{{ git_user }}"
when: ref_branch.rc == 0
changed_when: rev_before.stdout != target_rev
- name: Check out the tag or commit
ansible.builtin.command: "{{ git_cmd }} checkout --force --detach {{ target_rev }}"
become: "{{ git_user | length > 0 }}"
become_user: "{{ git_user }}"
when: ref_branch.rc != 0
changed_when: rev_before.stdout != target_rev
- name: Read the deployed revision
ansible.builtin.command: "{{ git_cmd }} rev-parse HEAD"
become: "{{ git_user | length > 0 }}"
become_user: "{{ git_user }}"
changed_when: false
register: rev_after
- name: Check that the clone is at the target revision
ansible.builtin.assert:
that: rev_after.stdout == target_rev
fail_msg: "Клон на {{ rev_after.stdout }}, ожидалось {{ target_rev }}."
quiet: true
- name: Remember the short revision
ansible.builtin.set_fact:
deploy_rev: "{{ rev_after.stdout[:12] }}"
@@ -0,0 +1,33 @@
---
# Порядок важен: сначала всё, что не трогает работающий контейнер (проверки,
# обновление кода, сборка образа), и только потом замена контейнера. Если
# что-то упало до replace, старый контейнер продолжает работать.
- name: Preflight checks
ansible.builtin.import_tasks: preflight.yml
tags: [preflight]
- name: Dry run stops after preflight
ansible.builtin.debug:
msg: "check mode: git, build, replace and verify are skipped"
when: ansible_check_mode
tags: [always]
- name: Update the git clone
ansible.builtin.import_tasks: git.yml
when: not ansible_check_mode
tags: [git]
- name: Build the image
ansible.builtin.import_tasks: build.yml
when: not ansible_check_mode
tags: [build]
- name: Replace the container
ansible.builtin.import_tasks: replace.yml
when: not ansible_check_mode
tags: [replace]
- name: Verify the new container
ansible.builtin.import_tasks: verify.yml
when: not ansible_check_mode
tags: [verify]
@@ -0,0 +1,150 @@
---
# --- на jump-хосте (один раз) -------------------------------------------
- name: Check that the env file exists on the jump host
ansible.builtin.stat:
path: "{{ local_env_file }}"
delegate_to: localhost
become: false
run_once: true
check_mode: false
register: env_file_stat
- name: Fail early without an env file
ansible.builtin.assert:
that: env_file_stat.stat.exists
fail_msg: >-
Нет env-файла {{ local_env_file }}. Создайте его:
cp env/validator-agent.env.example env/validator-agent.env и заполните.
quiet: true
run_once: true
# Содержимое файла (в нём токен) не выводится: разбор идёт в задаче с no_log,
# а проверка и её сообщение — по готовым булевым значениям.
- name: Inspect the env file without printing it
ansible.builtin.set_fact:
env_url_set: "{{ env_file_text is regex('(?m)^VALIDATOR_AGENT_CONTROL_API_URL=\\S+') }}"
env_url_is_example: "{{ env_file_text is regex('(?m)^VALIDATOR_AGENT_CONTROL_API_URL=\\S*example\\.com') }}"
vars:
env_file_text: "{{ lookup('ansible.builtin.file', local_env_file) }}"
run_once: true
no_log: true
- name: Check that the env file sets the control-api address
ansible.builtin.assert:
that:
- env_url_set | bool
- not (env_url_is_example | bool)
fail_msg: >-
В {{ local_env_file }} не задан VALIDATOR_AGENT_CONTROL_API_URL
(или остался адрес-пример example.com).
quiet: true
run_once: true
# --- на каждом валидаторе -----------------------------------------------
- name: Check that Docker answers
ansible.builtin.command: docker version --format {% raw %}'{{.Server.Version}}'{% endraw %}
changed_when: false
check_mode: false
- name: Check that git is installed
ansible.builtin.command: git --version
changed_when: false
check_mode: false
- name: Check that the git clone exists
ansible.builtin.stat:
path: "{{ repo_dir }}/.git"
check_mode: false
register: clone_stat
- name: Fail without a clone
ansible.builtin.assert:
that: clone_stat.stat.exists
fail_msg: "Нет git-клона {{ repo_dir }} на {{ inventory_hostname }}."
quiet: true
- name: Read the CPU architecture
ansible.builtin.command: uname -m
changed_when: false
check_mode: false
register: arch
- name: The image is linux/amd64 only
ansible.builtin.assert:
that: arch.stdout in ['x86_64', 'amd64']
fail_msg: "Архитектура {{ arch.stdout }}: образ {{ platform }} здесь не запустится (exec format error)."
quiet: true
- name: Look at the current container
ansible.builtin.command: >-
docker container inspect --format
{% raw %}'{{.Config.Image}} {{.State.Status}}'{% endraw %}
{{ container_name }}
register: current_container
changed_when: false
failed_when: false
check_mode: false
# Защита от второго агента: если на хосте уже есть другой контейнер этого
# агента (по имени или по образу), сценарий остановится, а не запустит
# рядом ещё один с тем же validator_id.
- name: List containers on the validator
ansible.builtin.command: docker ps -a --format {% raw %}'{{.Names}}|{{.Image}}'{% endraw %}
register: all_containers
changed_when: false
check_mode: false
- name: Check that there is no other agent container
ansible.builtin.assert:
that: (other_agents | from_json) | length == 0
fail_msg: >-
{{ inventory_hostname }}: найден другой контейнер агента: {{ (other_agents | from_json) | join(', ') }}.
Сценарий заменяет только контейнер {{ container_name }}. Проверьте container_name в
inventory/group_vars/validators.yml или удалите лишний контейнер вручную.
quiet: true
vars:
other_agents: >-
{%- set found = [] -%}
{%- for line in all_containers.stdout_lines -%}
{%- set row = line.split('|') -%}
{%- if row[0] != container_name and ('validator-agent' in row[0] or row[1] == image_name or row[1].startswith(image_name ~ ':')) -%}
{%- set _ = found.append(row[0] ~ ' (' ~ row[1] ~ ')') -%}
{%- endif -%}
{%- endfor -%}
{{- found | to_json -}}
# validator_id работающего контейнера — эталон: если он отличается от
# inventory, заменять контейнер нельзя (агент зарегистрировался бы под чужим
# именем, адреса привязывались бы к порту другой ВМ). Выводится только он,
# а не все переменные окружения (там токен).
- name: Read validator_id of the running container
ansible.builtin.shell: |
set -o pipefail
docker container inspect --format '{% raw %}{{range .Config.Env}}{{println .}}{{end}}{% endraw %}' {{ container_name }} \
| sed -n 's/^VALIDATOR_AGENT_VALIDATOR_ID=//p'
args:
executable: /bin/bash
register: running_validator_id
changed_when: false
failed_when: false
check_mode: false
when: current_container.rc == 0
- name: Check validator_id against the running container
ansible.builtin.assert:
that: >-
current_container.rc != 0
or (running_validator_id.stdout | trim) == ''
or (running_validator_id.stdout | trim) == effective_validator_id
fail_msg: >-
{{ inventory_hostname }}: в запущенном контейнере validator_id={{ running_validator_id.stdout | default('') | trim }},
а в inventory {{ effective_validator_id }}. Проверьте соответствие имени ВМ и validator_id
в inventory/hosts.yml; контейнер не тронут.
quiet: true
- name: Report the current container
ansible.builtin.debug:
msg: >-
{{ container_name }}:
{{ current_container.stdout if current_container.rc == 0 else 'контейнера нет (будет создан)' }};
validator_id для запуска: {{ effective_validator_id }}
@@ -0,0 +1,45 @@
---
# Образ уже собран: простой валидатора — только stop + rm + run.
- name: Copy the env file to the validator
ansible.builtin.copy:
src: "{{ local_env_file }}"
dest: "{{ remote_env_file }}"
owner: root
group: root
mode: "0600"
no_log: true
- name: Check whether the container exists
ansible.builtin.command: "docker container inspect {{ container_name }}"
register: container_exists
changed_when: false
failed_when: false
- name: Stop the current container
ansible.builtin.command: "docker stop -t {{ stop_timeout }} {{ container_name }}"
when: container_exists.rc == 0
- name: Remove the current container
ansible.builtin.command: "docker rm -f {{ container_name }}"
when: container_exists.rc == 0
# --restart нужен: агент завершается, если регистрация в control-api не
# удалась, и должен подняться снова. validator_id берётся из inventory
# (validator_id) и перекрывает env-файл.
- name: Start the new container
ansible.builtin.command:
argv: >-
{{ ['docker', 'run', '-d',
'--name', container_name,
'--restart', restart_policy,
'--platform', platform,
'--env-file', remote_env_file,
'-e', 'VALIDATOR_AGENT_VALIDATOR_ID=' ~ effective_validator_id,
'--log-driver', 'json-file',
'--log-opt', 'max-size=' ~ log_max_size,
'--log-opt', 'max-file=' ~ log_max_file,
'--label', 'git.rev=' ~ rev_after.stdout,
'--label', 'deployed.by=ansible']
+ (capabilities | map('regex_replace', '^(.*)$', '--cap-add=\1') | list)
+ [image_ref] }}
register: started
@@ -0,0 +1,65 @@
---
- name: Verify the new container
block:
# Агент пишет "registered" после успешной регистрации в control-api.
- name: Wait for the agent to register in control-api
ansible.builtin.command: "docker logs --tail 200 {{ container_name }}"
register: agent_logs
changed_when: false
until: agent_logs.stdout is search('msg=registered validator_id=' ~ effective_validator_id ~ '(\s|$)') or agent_logs.stderr is search('msg=registered validator_id=' ~ effective_validator_id ~ '(\s|$)')
retries: "{{ verify_retries | int }}"
delay: "{{ verify_delay | int }}"
- name: Inspect the container
ansible.builtin.command:
argv: [docker, inspect, --format, "{% raw %}{{.State.Running}} {{.RestartCount}} {{.Image}}{% endraw %}", "{{ container_name }}"]
register: container_state
changed_when: false
- name: Check the container state
ansible.builtin.assert:
that:
- container_state.stdout.split()[0] == 'true'
- container_state.stdout.split()[1] == '0'
- container_state.stdout.split()[2] == built_image.stdout
fail_msg: >-
Контейнер {{ container_name }} в состоянии «{{ container_state.stdout }}»
(ожидалось: запущен, 0 перезапусков, образ {{ built_image.stdout }}).
quiet: true
rescue:
- name: Collect the container log
ansible.builtin.command: "docker logs --tail 30 {{ container_name }}"
register: failed_logs
changed_when: false
failed_when: false
- name: Fail the host and stop the next waves
ansible.builtin.fail:
msg: |-
{{ inventory_hostname }}: контейнер не прошёл проверку после запуска.
Последние строки лога:
{{ failed_logs.stdout }}{{ failed_logs.stderr }}
# Старые образы с метками ревизий: оставляем keep_images последних (docker
# выводит от новых к старым), образ работающего контейнера docker не удалит.
- name: List image tags
ansible.builtin.command:
argv: [docker, images, "{{ image_name }}", --format, "{% raw %}{{.Tag}}{% endraw %}"]
register: image_tags
changed_when: false
- name: Remove old revision images
ansible.builtin.command: "docker rmi {{ image_name }}:{{ item }}"
loop: "{{ (image_tags.stdout_lines | reject('equalto', 'latest') | list)[keep_images | int:] }}"
changed_when: true
failed_when: false
- name: Remove dangling images
ansible.builtin.command: docker image prune -f
changed_when: false
- name: Summary
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }} ({{ effective_validator_id }}): {{ deploy_rev }} ({{ deploy_ref }}),
образ {{ built_image.stdout[:19] }}, контейнер {{ container_name }} запущен
@@ -0,0 +1,7 @@
---
# git с явным safe.directory: клон может принадлежать другому пользователю.
git_cmd: "git -c safe.directory={{ repo_dir }} -C {{ repo_dir }}"
# validator_id в control-api: из inventory, иначе имя хоста.
effective_validator_id: "{{ validator_id | default(inventory_hostname) }}"
# Образ с меткой ревизии, собранный в этом прогоне.
image_ref: "{{ image_name }}:{{ deploy_rev | default('unknown') }}"
+13
View File
@@ -14,6 +14,19 @@ PROBER_SITE_ID=site-1
# --- validator-agent (must match a validator_id in the mounted control-api config) ---
VALIDATOR_AGENT_VALIDATOR_ID=validator_01
# --- authentication (empty = that level stays open; services log a warning) ---
# Generate each secret with: openssl rand -hex 32
# control-api admin token (protects /api/v1/admin/*) and the dashboard's copy of it:
CONTROL_API_ADMIN_TOKEN=
ADMIN_DASHBOARD_CONTROL_API_TOKEN=
# control-api agent token (protects result/event writes); the same value goes
# to prober and validator-agent:
CONTROL_API_AGENT_TOKEN=
# Dashboard login (both must be set to enable it) and cookie-signing key:
ADMIN_DASHBOARD_USERNAME=
ADMIN_DASHBOARD_PASSWORD=
ADMIN_DASHBOARD_SESSION_SECRET=
# ADMIN_DASHBOARD_CONTROL_API_URL / PROBER_CONTROL_API_URL /
# VALIDATOR_AGENT_CONTROL_API_URL default to http://control-api:8080 in
# docker-compose.yml (same Docker network) — only set here to override.
+16
View File
@@ -39,3 +39,19 @@ VALIDATOR_AGENT_CONTROL_API_URL=https://control-api.internal.example.com
# --- admin-dashboard (only needed on a dashboard-profile host) ---
ADMIN_DASHBOARD_CONTROL_API_URL=http://control-api:8080
# --- authentication (fill in only what this host needs; empty = open + warning) ---
# Generate each secret with: openssl rand -hex 32
# Rollout order without downtime: update all binaries first, then give the
# tokens to prober / validator-agent / dashboard, and set the control-api
# tokens (and restart it) last.
#
# control-plane host: tokens that control-api enforces
CONTROL_API_ADMIN_TOKEN=
CONTROL_API_AGENT_TOKEN=
# dashboard host: control-api's admin token, the login, and the cookie-signing key
ADMIN_DASHBOARD_CONTROL_API_TOKEN=
ADMIN_DASHBOARD_USERNAME=
ADMIN_DASHBOARD_PASSWORD=
ADMIN_DASHBOARD_SESSION_SECRET=
# prober / validator hosts use CONTROL_API_AGENT_TOKEN (same value as above)
+25
View File
@@ -25,6 +25,7 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name prober \
-e PROBER_TCP_TIMEOUT_SECONDS=5 \
-e PROBER_ICMP_TIMEOUT_SECONDS=5 \
-e PROBER_ICMP_COUNT=3 \
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
cloud-ip-validator-prober
Рабочий пример (стенд 62.176.10.113):
@@ -42,6 +43,9 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name prober \
PROBER_TCP_TIMEOUT_SECONDS опциональная, дефолт 5
PROBER_ICMP_TIMEOUT_SECONDS опциональная, дефолт 5
PROBER_ICMP_COUNT опциональная, дефолт 3
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api (то же
значение, что у control-api); без него результаты
проверок будут отклонены, если токен включён
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
@@ -61,6 +65,10 @@ docker run -d --platform linux/amd64 -p 8090:8090 --name admin-dashboard \
-e ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS=10 \
-e ADMIN_DASHBOARD_LAST_COMPLETED_COUNT=20 \
-e ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS=5 \
-e ADMIN_DASHBOARD_CONTROL_API_TOKEN=<admin_token> \
-e ADMIN_DASHBOARD_USERNAME=<login> \
-e ADMIN_DASHBOARD_PASSWORD=<password> \
-e ADMIN_DASHBOARD_SESSION_SECRET=<random_hex> \
cloud-ip-validator-admin-dashboard
Рабочий пример (стенд 62.176.10.113):
@@ -75,6 +83,14 @@ docker run -d --platform linux/amd64 -p 8090:8090 --name admin-dashboard \
ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS опциональная, дефолт 10
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT опциональная, дефолт 20
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS опциональная, дефолт 5
ADMIN_DASHBOARD_CONTROL_API_TOKEN опциональная: admin-токен control-api
ADMIN_DASHBOARD_USERNAME / _PASSWORD опциональные: логин дашборда; если не
заданы оба — вход не требуется (в логе
предупреждение)
ADMIN_DASHBOARD_SESSION_SECRET опциональная: ключ подписи cookie сессии;
без неё случайный, сессии сбрасываются
рестартом
(секреты генерируются так: openssl rand -hex 32)
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
@@ -102,12 +118,18 @@ docker run -d --platform linux/amd64 -p 8080:8080 --name control-api \
-e OS_PROJECT_ID=<project_id> \
-e OS_REGION_NAME=<region> \
-e OS_TOKEN=<token> \
-e CONTROL_API_ADMIN_TOKEN=<admin_token> \
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
cloud-ip-validator-control-api
(при auth_method: password — вместо OS_TOKEN передать OS_USERNAME,
OS_USER_DOMAIN_NAME, OS_PASSWORD; для openstack.mode: mock креденшлы не
нужны)
CONTROL_API_ADMIN_TOKEN закрывает /api/v1/admin/*, CONTROL_API_AGENT_TOKEN —
запись результатов/событий агентами и пробером; не заданный токен оставляет
соответствующий уровень открытым (в логе предупреждение).
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
-p 8080:8080 проброс порта control-api наружу
@@ -132,6 +154,7 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
-e VALIDATOR_AGENT_ICMP_COUNT=3 \
-e VALIDATOR_AGENT_SSH_ENABLED=false \
-e VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5 \
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
cloud-ip-validator-validator-agent
Переменные окружения:
@@ -145,6 +168,8 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
VALIDATOR_AGENT_ICMP_COUNT опциональная, дефолт 3
VALIDATOR_AGENT_SSH_ENABLED опциональная, дефолт false
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS опциональная, дефолт 5
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api
(то же значение, что у control-api)
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
@@ -8,6 +8,10 @@ export ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS="${ADMIN_DASHBOARD_CONTROL_AP
export ADMIN_DASHBOARD_LAST_COMPLETED_COUNT="${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
export ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS="${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
# NOT templated into the YAML: the binary reads them straight from this
# container's environment (names default in the config loader), so secrets
# never land in a file inside the container.
envsubst '${ADMIN_DASHBOARD_LISTEN_ADDR} ${ADMIN_DASHBOARD_CONTROL_API_URL} ${ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS} ${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT} ${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS}' \
< /etc/admin-dashboard/admin-dashboard.yaml.tmpl > /etc/admin-dashboard/admin-dashboard.yaml
@@ -16,6 +16,15 @@ database:
openstack:
mode: "mock"
# Постраничное чтение Floating IP / page size of the paged listing (default 200)
list_page_size: 200
# Статические bearer-токены читаются из переменных окружения контейнера с
# этими именами (задаются в .env, см. deploy/docker/.env.example). Пусто =
# соответствующий уровень API открыт (с предупреждением в логе).
auth:
admin_token_env: "CONTROL_API_ADMIN_TOKEN" # защищает /api/v1/admin/*
agent_token_env: "CONTROL_API_AGENT_TOKEN" # защищает запись результатов агентами/пробером
orchestrator:
poll_interval_seconds: 5
@@ -27,6 +36,7 @@ orchestrator:
heartbeat_timeout_seconds: 30
fip_settle_seconds: 0
fip_scan_interval_seconds: 0
fip_scan_timeout_seconds: 1800 # общий таймаут фонового скана / scan deadline
aggregation:
missing_counts_as_fail: true
+16
View File
@@ -34,6 +34,11 @@ services:
networks: [backend]
volumes:
- control-api-db:/var/lib/cloud-ip-validator
environment:
# Static bearer tokens (empty = that API level stays open, with a
# warning in the log). Generate with `openssl rand -hex 32`.
CONTROL_API_ADMIN_TOKEN: "${CONTROL_API_ADMIN_TOKEN:-}"
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
interval: 5s
@@ -55,6 +60,12 @@ services:
ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS: "${ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS:-10}"
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT: "${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS: "${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
# Admin token of control-api, and the dashboard's own login. Empty
# username/password = login disabled (warning in the log).
ADMIN_DASHBOARD_CONTROL_API_TOKEN: "${ADMIN_DASHBOARD_CONTROL_API_TOKEN:-}"
ADMIN_DASHBOARD_USERNAME: "${ADMIN_DASHBOARD_USERNAME:-}"
ADMIN_DASHBOARD_PASSWORD: "${ADMIN_DASHBOARD_PASSWORD:-}"
ADMIN_DASHBOARD_SESSION_SECRET: "${ADMIN_DASHBOARD_SESSION_SECRET:-}"
prober:
build:
@@ -72,6 +83,8 @@ services:
PROBER_TCP_TIMEOUT_SECONDS: "${PROBER_TCP_TIMEOUT_SECONDS:-5}"
PROBER_ICMP_TIMEOUT_SECONDS: "${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
PROBER_ICMP_COUNT: "${PROBER_ICMP_COUNT:-3}"
# Agent token of control-api (same value as on control-api).
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
validator-agent:
build:
@@ -87,8 +100,11 @@ services:
VALIDATOR_AGENT_CONTROL_API_URL: "${VALIDATOR_AGENT_CONTROL_API_URL:-http://control-api:8080}"
VALIDATOR_AGENT_POLL_INTERVAL_SECONDS: "${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS:-5}"
VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS:-10}"
VALIDATOR_AGENT_SELF_CHECK_METHODS: "${VALIDATOR_AGENT_SELF_CHECK_METHODS:-[ip_echo]}"
VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS:-10}"
VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS:-5}"
VALIDATOR_AGENT_ICMP_COUNT: "${VALIDATOR_AGENT_ICMP_COUNT:-3}"
VALIDATOR_AGENT_SSH_ENABLED: "${VALIDATOR_AGENT_SSH_ENABLED:-false}"
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
# Agent token of control-api (same value as on control-api).
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
@@ -9,6 +9,10 @@ export PROBER_TCP_TIMEOUT_SECONDS="${PROBER_TCP_TIMEOUT_SECONDS:-5}"
export PROBER_ICMP_TIMEOUT_SECONDS="${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
export PROBER_ICMP_COUNT="${PROBER_ICMP_COUNT:-3}"
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
# NOT templated into the YAML: the binary reads them straight from this
# container's environment (names default in the config loader), so secrets
# never land in a file inside the container.
envsubst '${PROBER_SITE_ID} ${PROBER_CONTROL_API_URL} ${PROBER_POLL_INTERVAL_SECONDS} ${PROBER_TCP_TIMEOUT_SECONDS} ${PROBER_ICMP_TIMEOUT_SECONDS} ${PROBER_ICMP_COUNT}' \
< /etc/prober/prober.yaml.tmpl > /etc/prober/prober.yaml
@@ -6,13 +6,18 @@ set -eu
export VALIDATOR_AGENT_POLL_INTERVAL_SECONDS="${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS:-5}"
export VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS="${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS:-10}"
export VALIDATOR_AGENT_SELF_CHECK_METHODS="${VALIDATOR_AGENT_SELF_CHECK_METHODS:-[ip_echo]}"
export VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS="${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS:-10}"
export VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS="${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS:-5}"
export VALIDATOR_AGENT_ICMP_COUNT="${VALIDATOR_AGENT_ICMP_COUNT:-3}"
export VALIDATOR_AGENT_SSH_ENABLED="${VALIDATOR_AGENT_SSH_ENABLED:-false}"
export VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS="${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
envsubst '${VALIDATOR_AGENT_VALIDATOR_ID} ${VALIDATOR_AGENT_CONTROL_API_URL} ${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS} ${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_COUNT} ${VALIDATOR_AGENT_SSH_ENABLED} ${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS}' \
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
# NOT templated into the YAML: the binary reads them straight from this
# container's environment (names default in the config loader), so secrets
# never land in a file inside the container.
envsubst '${VALIDATOR_AGENT_VALIDATOR_ID} ${VALIDATOR_AGENT_CONTROL_API_URL} ${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS} ${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_SELF_CHECK_METHODS} ${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_COUNT} ${VALIDATOR_AGENT_SSH_ENABLED} ${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS}' \
< /etc/validator-agent/validator-agent.yaml.tmpl > /etc/validator-agent/validator-agent.yaml
exec /usr/local/bin/validator-agent -config /etc/validator-agent/validator-agent.yaml
@@ -4,6 +4,7 @@ poll_interval_seconds: ${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS}
self_check:
timeout_seconds: ${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS}
methods: ${VALIDATOR_AGENT_SELF_CHECK_METHODS}
checks:
https_timeout_seconds: ${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS}
+5
View File
@@ -8,6 +8,11 @@ Type=simple
User=cloud-ip-validator
Group=cloud-ip-validator
ExecStart=/usr/local/bin/admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
# Optional (leading "-": the file may be absent). Holds
# ADMIN_DASHBOARD_CONTROL_API_TOKEN (control-api's admin token) and the
# dashboard login: ADMIN_DASHBOARD_USERNAME, ADMIN_DASHBOARD_PASSWORD,
# ADMIN_DASHBOARD_SESSION_SECRET. Keep it mode 0600, owned by the service user.
EnvironmentFile=-/etc/cloud-ip-validator/admin-dashboard.env
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
+3 -1
View File
@@ -13,7 +13,9 @@ ExecStart=/usr/local/bin/control-api -config /etc/cloud-ip-validator/control-api
# (auth_method: token) or OS_USERNAME / OS_USER_DOMAIN_NAME / OS_PASSWORD
# (auth_method: password) — see docs/SETUP.md. Keep this file mode 0600,
# owned by the service user; never commit it or put credentials in the
# YAML config.
# YAML config. Also holds CONTROL_API_ADMIN_TOKEN and CONTROL_API_AGENT_TOKEN
# (generate each with `openssl rand -hex 32`); when unset, the matching API
# level stays open and control-api logs a warning.
EnvironmentFile=/etc/cloud-ip-validator/control-api.env
WorkingDirectory=/var/lib/cloud-ip-validator
Restart=on-failure
+4
View File
@@ -8,6 +8,10 @@ Type=simple
User=cloud-ip-validator
Group=cloud-ip-validator
ExecStart=/usr/local/bin/prober -config /etc/cloud-ip-validator/prober.yaml
# Optional (leading "-": the file may be absent). Holds CONTROL_API_AGENT_TOKEN,
# the same value as on control-api. Keep it mode 0600, owned by the service
# user.
EnvironmentFile=-/etc/cloud-ip-validator/prober.env
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
+4
View File
@@ -8,6 +8,10 @@ Type=simple
User=cloud-ip-validator
Group=cloud-ip-validator
ExecStart=/usr/local/bin/validator-agent -config /etc/cloud-ip-validator/validator-agent.yaml
# Optional (leading "-": the file may be absent). Holds CONTROL_API_AGENT_TOKEN,
# the same value as on control-api. Keep it mode 0600, owned by the service
# user.
EnvironmentFile=-/etc/cloud-ip-validator/validator-agent.env
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
+248
View File
@@ -0,0 +1,248 @@
# Ручная очистка базы данных control-api (SQL)
Когда нужна: подготовка к новому полному прогону, разбор после инцидента, освобождение места, удаление отдельных адресов.
Все примеры проверены 2026-10-02 на копии боевой БД (20 валидаторов, 4 площадки, 6445 адресов в реестре, 29 889 проверок,
19 897 событий): без ошибок, `integrity_check` = `ok`, нарушений внешних ключей нет.
> **Сначала API.** Если в очереди есть адреса в работе, очищайте очередь штатно: кнопка «Очистить всё» на `/ips` или
> `POST /api/v1/admin/ips/clear` (см. [USAGE.md](USAGE.md#удаление-адресов-из-очереди)). Только так control-api отвяжет Floating IP от портов
> валидаторов. SQL ниже работает с базой «в покое»: он не обращается к OpenStack.
## 1. Что в базе и что нельзя трогать
| Группа | Таблицы | Можно чистить |
|---|---|---|
| Данные прогона | `ip_queue` (очередь), `ip_registry` (реестр адресов), `checks` (реестр проверок), `ip_site_checks` (признаки площадок по адресам в работе), `events` (журнал событий) | да |
| Настройки (не трогать) | `validators`, `sites`, `target_groups` (цели), `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle` | **нет** |
| Служебное | `sqlite_sequence` (нумерация записей), `PRAGMA user_version` (версия схемы) | нумерацию можно сбросить, версию не менять |
Связи (внешние ключи): `checks`, `events`, `ip_site_checks` ссылаются на `ip_queue`; `checks`, `events`, `ip_queue` — на `ip_registry`;
`validators.current_ip_id` — на `ip_queue`. Поэтому порядок удаления всегда такой: сначала `validators.current_ip_id` в `NULL`, затем
`ip_site_checks`, `checks`, `events`, `ip_queue` и в конце `ip_registry`. Времена в БД хранятся строками `2026-10-02T07:14:58.857Z` (UTC).
## 2. Подготовка (всегда, перед любой очисткой)
**2.1. Где лежит база и чем её открывать.** Нужен клиент `sqlite3` на хосте (`apt install sqlite3`).
| Развёртывание | Файл базы |
|---|---|
| `rxprod-compose/` (боевой стенд) | `rxprod-compose/capi-db/control-api.db` |
| Docker (`deploy/docker`) | в томе `cloud-ip-validator-db`: `docker volume inspect cloud-ip-validator-db --format '{{.Mountpoint}}'`, файл `control-api.db` в этом каталоге (нужен root) |
| systemd | `/var/lib/cloud-ip-validator/control-api.db` (`database.path` в `control-api.yaml`) |
Дальше в примерах `DB=путь/к/control-api.db`.
**2.2. Проверить, что в очереди ничего не в работе:**
```bash
sqlite3 -readonly "$DB" "
SELECT state, COUNT(*) FROM ip_queue
WHERE state NOT IN ('done', 'failed', 'occupied') GROUP BY state;"
```
Пустой вывод — всё завершено. Адреса в работе есть — сначала «Очистить всё» через API (см. выше). Затем проверьте в OpenStack, что на портах
валидаторов нет лишних Floating IP; по базе видно только то, что control-api считает привязанным:
```bash
sqlite3 -readonly "$DB" "
SELECT ip_address, state, fip_id FROM ip_queue
WHERE fip_id <> '' AND state NOT IN ('done', 'failed', 'occupied');"
```
**2.3. Остановить control-api.** Он держит базу открытой и пишет в неё на каждом такте; ручные правки поверх работающего процесса
ненадёжны.
```bash
cd rxprod-compose && docker compose stop control-api # compose-развёртывание
sudo systemctl stop control-api # systemd
```
**2.4. Сделать резервную копию** (штатной командой SQLite, не `cp`: у базы есть журнал `-wal`):
```bash
TS=$(date -u +%Y-%m-%d_%H-%M)
sqlite3 "$DB" ".backup '$(dirname "$DB")/backup-$TS-before-cleanup.db'"
sqlite3 -readonly "$(dirname "$DB")/backup-$TS-before-cleanup.db" "PRAGMA integrity_check;" # должно быть: ok
```
Лог контейнера до чистки при необходимости сохраните отдельно: `docker logs <контейнер> > backup-$TS.container.log 2>&1`.
**2.5. Посмотреть, что и сколько лежит** (до и после чистки):
```bash
sqlite3 -readonly "$DB" "
SELECT 'ip_queue' AS tbl, COUNT(*) AS n FROM ip_queue
UNION ALL SELECT 'ip_registry', COUNT(*) FROM ip_registry
UNION ALL SELECT 'checks', COUNT(*) FROM checks
UNION ALL SELECT 'ip_site_checks', COUNT(*) FROM ip_site_checks
UNION ALL SELECT 'events', COUNT(*) FROM events
UNION ALL SELECT 'validators', COUNT(*) FROM validators
UNION ALL SELECT 'sites', COUNT(*) FROM sites;"
```
## 3. Сценарии
Команды выполняются так: `sqlite3 "$DB"` и вставить блок, либо сохранить блок в файл и выполнить `sqlite3 "$DB" < файл.sql`.
### 3.1. Полный сброс данных прогона (перед новым полным прогоном)
Очищает очередь, реестр адресов, реестр проверок, журнал событий. Валидаторы, площадки, цели, типы проверок и все настройки остаются.
```sql
PRAGMA foreign_keys = ON;
BEGIN;
-- валидаторы больше не ссылаются на адреса очереди
UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IS NOT NULL;
UPDATE validators SET state = 'idle' WHERE state = 'assigned';
-- порядок важен: сначала зависимые таблицы
DELETE FROM ip_site_checks;
DELETE FROM checks;
DELETE FROM events;
DELETE FROM ip_queue;
DELETE FROM ip_registry;
-- нумерация снова с 1 (необязательно)
DELETE FROM sqlite_sequence WHERE name IN ('ip_registry', 'checks', 'ip_queue', 'events');
COMMIT;
```
Затем освободите место (отдельной командой, не внутри транзакции):
```sql
PRAGMA wal_checkpoint(TRUNCATE);
VACUUM;
```
Файл сжимается до сотен килобайт (на проверочной копии: 20 МБ → 128 КБ).
### 3.2. Только журнал событий
Очередь, реестр и проверки не затрагиваются.
Весь журнал:
```sql
DELETE FROM events;
DELETE FROM sqlite_sequence WHERE name = 'events';
```
Только старше 7 дней (число дней меняйте в `'-7 days'`):
```sql
DELETE FROM events
WHERE occurred_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days');
```
### 3.3. Только реестр проверок (история проверок)
Адреса и их итоги в очереди остаются; пропадает подробная история проверок (на странице «Реестр» исчезнут результаты).
Вся история:
```sql
DELETE FROM checks;
DELETE FROM sqlite_sequence WHERE name = 'checks';
```
Оставить последние 3 цикла каждого адреса (число `3` меняйте):
```sql
DELETE FROM checks
WHERE cycle_id <= (SELECT MAX(c2.cycle_id) FROM checks c2 WHERE c2.registry_id = checks.registry_id) - 3;
```
Постоянное ограничение глубины истории лучше задать настройкой `history_retention_cycles` (страница `/settings` или
`PUT /api/v1/admin/config/orchestrator`): control-api сам подрезает историю при завершении каждого адреса. SQL выше нужен для разовой чистки.
### 3.4. Удалить конкретные адреса целиком
Удаляет адрес из очереди и реестра вместе со всей его историей (проверки и события). Список адресов подставьте в первую команду
`CREATE TEMP TABLE doomed_reg`. Адрес, который сейчас проверяется, удалять этим способом нельзя: используйте API (раздел 5).
```sql
PRAGMA foreign_keys = ON;
BEGIN;
CREATE TEMP TABLE doomed_reg AS
SELECT id FROM ip_registry WHERE ip_address IN ('5.188.140.6', '5.188.140.62'); -- ваши адреса
CREATE TEMP TABLE doomed_ip AS
SELECT id FROM ip_queue WHERE registry_id IN (SELECT id FROM doomed_reg);
UPDATE validators SET current_ip_id = NULL WHERE current_ip_id IN (SELECT id FROM doomed_ip);
DELETE FROM ip_site_checks WHERE ip_id IN (SELECT id FROM doomed_ip);
DELETE FROM checks WHERE registry_id IN (SELECT id FROM doomed_reg);
DELETE FROM events WHERE registry_id IN (SELECT id FROM doomed_reg) OR ip_id IN (SELECT id FROM doomed_ip);
DELETE FROM ip_queue WHERE id IN (SELECT id FROM doomed_ip);
DELETE FROM ip_registry WHERE id IN (SELECT id FROM doomed_reg);
DROP TABLE doomed_ip;
DROP TABLE doomed_reg;
COMMIT;
```
### 3.5. Только освободить место
Если удалили много, а файл не уменьшился (SQLite не отдаёт место ОС до `VACUUM`):
```sql
PRAGMA wal_checkpoint(TRUNCATE);
VACUUM;
```
Размер и свободные страницы:
```sql
SELECT page_count * page_size / 1024 AS size_kb, freelist_count * page_size / 1024 AS free_kb
FROM pragma_page_count(), pragma_page_size(), pragma_freelist_count();
```
## 4. После очистки: запуск и проверка
```bash
cd rxprod-compose && docker compose up -d --no-deps control-api # или: sudo systemctl start control-api
```
Если нужно, чтобы и лог контейнера начался с нуля, пересоздайте контейнер: `docker compose up -d --force-recreate --no-deps control-api`
(старый лог сохраните заранее, п. 2.4).
Проверка базы (до запуска или на копии):
```bash
sqlite3 -readonly "$DB" "
PRAGMA integrity_check;
PRAGMA foreign_key_check;
SELECT state, COUNT(*) FROM validators GROUP BY state;
SELECT COUNT(*) AS validators_with_address FROM validators WHERE current_ip_id IS NOT NULL;
SELECT COUNT(*) AS queue_rows FROM ip_queue;"
```
Ожидается: `ok`; пустой результат `foreign_key_check`; у валидаторов состояние `idle`; `validators_with_address` = 0; для полного сброса `queue_rows` = 0.
Через API: `GET /api/v1/admin/status` (`total_ips` = 0, `total_validators` = число валидаторов) и `GET /api/v1/admin/validators`
(через 10–15 секунд после запуска у всех свежий `last_heartbeat_at`).
## 5. Что делать через API, а не через SQL
| Задача | Как |
|---|---|
| Остановить проверку адреса, удалить адрес в работе | `POST /api/v1/admin/ips/{ip}/cancel`, `DELETE /api/v1/admin/ips/{ip}` |
| Очистить всю очередь с отвязкой Floating IP | `POST /api/v1/admin/ips/clear` |
| Перепроверить завершённые адреса | `POST /api/v1/admin/ips` со списком адресов |
| Валидатор «завис» с адресом | ничего не править: лизинг истечёт, адрес вернётся в очередь, валидатор освободится сам |
| Добавить/убрать валидатор, площадку, цель | `/api/v1/admin/config/*` или страницы дашборда |
## 6. Восстановление из копии
```bash
cd rxprod-compose && docker compose stop control-api
cp capi-db/backup-<метка>-before-cleanup.db capi-db/control-api.db
rm -f capi-db/control-api.db-wal capi-db/control-api.db-shm # старый журнал к новой копии не относится
docker compose up -d --no-deps control-api
```
## 7. Ловушки
- Не выполняйте `DELETE` при работающем control-api: он пишет в ту же базу.
- Не удаляйте строки настроек (`validators`, `sites`, `target_groups`, `check_types`, `inbound_checks_settings`, `settings`, `auto_cycle`):
после этого control-api либо не стартует, либо работает без площадок и целей.
- Не нарушайте порядок удаления (раздел 1) и не отключайте `PRAGMA foreign_keys = ON` в блоках выше: база сама остановит ошибочное удаление.
- `VACUUM` нельзя вызывать внутри транзакции и пока control-api запущен.
- Не копируйте файл базы командой `cp` при работающем процессе: журнал `-wal` останется в неконсистентном состоянии. Используйте `.backup`.
- Не меняйте `PRAGMA user_version`: по нему control-api применяет миграции схемы.
- Не правьте состояние адресов и валидаторов вручную (`state`, `owner_validator_id`, `lease_expires_at`) вместо API: control-api сверяет их на каждом такте и исправит расхождение,
но до этого результат непредсказуем.
+194 -31
View File
@@ -4,15 +4,13 @@ Control API — единственная точка входа в систему
`prober` и оператора (администратора). Все данные передаются в формате
JSON, базовый префикс прикладных методов — `/api/v1`.
> **Важно.** На данный момент API не защищён аутентификацией/авторизацией
> — эндпоинты доступны любому, кто может достучаться до порта control-api
> по сети. Это касается и методов из раздела
> [«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией)
> ниже — они меняют, что и как проверяется, без подтверждения личности
> вызывающего. Для эксплуатации за пределами доверенного сегмента сети
> обязательно ограничьте доступ на уровне сети/файрвола (см.
> [SETUP.md](SETUP.md#сетевые-доступы)). Добавление bearer-токена — известное
> направление доработки, в текущей версии не реализовано.
> **Аутентификация.** Доступ к API определяется двумя статическими
> bearer-токенами — см. [«Аутентификация»](#аутентификация). Токен задаётся
> переменной окружения; **если токен не задан, соответствующий уровень остаётся
> открытым** (control-api стартует с предупреждением в логе) — так сделано для
> обратной совместимости. Поэтому для эксплуатации за пределами доверенного
> сегмента сети токены нужно задать, а доступ дополнительно ограничить на уровне
> сети/файрвола (см. [SETUP.md](SETUP.md#сетевые-доступы)).
Базовый URL в примерах — `http://control-api.internal:8080`, замените на
адрес вашего стенда (см. `server.listen_addr` в конфиге control-api).
@@ -23,15 +21,42 @@ JSON, базовый префикс прикладных методов — `/ap
## Содержание
- [Аутентификация](#аутентификация)
- [Общие соглашения](#общие-соглашения)
- [Методы для validator-agent](#методы-для-validator-agent)
- [Методы для prober](#методы-для-prober)
- [Служебные и административные методы](#служебные-и-административные-методы)
- [Управление очередью и конфигурацией](#управление-очередью-и-конфигурацией)
- [Автоматический цикл проверок](#автоматический-цикл-проверок)
- [Реестр адресов и история проверок](#реестр-адресов-и-история-проверок)
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
- [Сквозной пример работы (curl)](#сквозной-пример-работы-curl)
## Аутентификация
Токен передаётся заголовком `Authorization: Bearer <токен>`. Токены статические, **без срока жизни**; ротация — смена
переменной окружения и перезапуск. Сравнение выполняется в константное время.
| Уровень | Токен (переменная на control-api) | Какие методы |
|---|---|---|
| **admin** | `CONTROL_API_ADMIN_TOKEN` | все `/api/v1/admin/*` (очередь, реестр, автоцикл, `config/*`) |
| **agent** | `CONTROL_API_AGENT_TOKEN` | запись результатов: `POST /agents/{id}/self-check`, `/events`, `/results`, `/complete` и `POST /probers/{site_id}/results` |
| **открыто** | — | `GET /healthz`; `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`, `GET /agents/{id}/observed-ip`; `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments` |
- Токены разные: токен администратора **не** подходит для методов агентов, и наоборот.
- Валидатор и пробер могут без токена зарегистрироваться, слать heartbeat и забирать задание (настройку); валидатор также может спросить, с какого адреса его видит control-api (`observed-ip`); отправка результатов без токена агентов — `401`.
- Имена переменных меняются в секции `auth` конфига control-api (`admin_token_env`, `agent_token_env`); сами значения в YAML не хранятся.
- Ответ при отказе: `401 {"error": "unauthorized"}` с заголовком `WWW-Authenticate: Bearer`.
- Токен не задан (пустая переменная) — уровень открыт; в логе control-api при старте предупреждение. Токены нужно генерировать случайными: `openssl rand -hex 32`.
- Токены уходят открытым текстом, если TLS не терминируется перед control-api, — публикуйте API через reverse-proxy с TLS.
```bash
export ADMIN_TOKEN=... # значение CONTROL_API_ADMIN_TOKEN
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://<control-api>:8080/api/v1/admin/status
```
> Во всех примерах `curl` ниже заголовок `Authorization` для краткости опущен; если токен администратора задан, добавляйте его к методам `/api/v1/admin/*`.
## Общие соглашения
- Тело запроса и ответа — JSON (`Content-Type: application/json`).
@@ -120,6 +145,30 @@ JSON, базовый префикс прикладных методов — `/ap
`check_config` — уже развёрнутая конфигурация проверок (тип + список
целей), агенту не нужно самому сопоставлять группы целей.
### `GET /api/v1/agents/{id}/observed-ip`
С какого адреса control-api видит соединение валидатора. Используется
self-check способом `control_api` (`self_check.methods` в
`validator-agent.yaml`) как альтернатива внешнему IP-echo сервису. Уровень
доступа — открыто: отдаётся только адрес самого вызывающего.
Ответ `200`:
```json
{"ip": "203.0.113.10", "source": "remote_addr"}
```
- Адрес берётся только из адреса TCP-соединения (`RemoteAddr`), приведённого
к каноничному виду (`::ffff:1.2.3.4` → `1.2.3.4`). Заголовки
`X-Forwarded-For` / `X-Real-IP` **не учитываются**: иначе валидатор мог бы
подделать адрес и пройти проверку. Метод рассчитан на прямое подключение
без обратного прокси.
- `404`, если `validator_id` не зарегистрирован.
- Способ корректен, только если соединение выходит через внешнюю сеть
(SNAT Floating IP). Если control-api достижим из облака по внутренней
сети, он увидит приватный адрес валидатора. Если порт control-api
опубликован через Docker, проверьте, что ручка показывает внешний адрес
клиента, а не адрес шлюза Docker.
### `POST /api/v1/agents/{id}/self-check`
Отчёт о результате self-check — подтверждение, что исходящий трафик
@@ -127,7 +176,11 @@ JSON, базовый префикс прикладных методов — `/ap
определяет это **сам**, обращаясь к внешнему (снаружи облака) IP-echo
сервису (`self_check.ip_echo_urls` в `validator-agent.yaml`, например
`api.ipify.org`) и сравнивая ответ с `ip_address` из задания — control-api
в этом определении не участвует. Важно, что ресурс должен быть именно
в этом определении не участвует. Дополнительно можно включить способ
`control_api` (`self_check.methods`): агент спрашивает у control-api через
`GET /agents/{id}/observed-ip`, с какого адреса тот его видит. Способы
пробуются по приоритету, достаточно подтверждения любым из них; без
настройки работает только IP-echo. Важно, что ресурс должен быть именно
внешним: OpenStack применяет SNAT через Floating IP только к трафику,
уходящему через внешнюю сеть, поэтому обращение к чему-либо внутри
проекта (в том числе к самому control-api, если он в той же внутренней
@@ -140,7 +193,7 @@ JSON, базовый префикс прикладных методов — `/ap
"ip_id": 42,
"detected_egress_ip": "203.0.113.10",
"success": true,
"detail": "matched"
"detail": "matched (control_api)"
}
```
@@ -305,15 +358,37 @@ IP на данном проходе". До этого момента control-api
{
"total_ips": 25,
"ips_by_state": {"queued": 10, "checking": 3, "done": 11, "failed": 1},
"results_by_overall": {"pass": 8, "partial": 3, "fail": 0, "cancelled": 0},
"total_validators": 4
}
```
`results_by_overall` — сколько адресов с каким итогом (всегда все четыре ключа). Счётчики считаются
запросами `GROUP BY` на стороне БД, а не загрузкой всей очереди, поэтому метод быстрый и при тысячах адресов.
### `GET /api/v1/admin/ips`
Полный список всех IP из очереди со всеми полями (см.
Список IP из очереди со всеми полями (см.
[USAGE.md](USAGE.md#значения-полей-ip) — расшифровка полей и статусов).
**Без параметров** — как раньше: весь список одним массивом (при тысячах адресов это мегабайты — для больших очередей
используйте постраничный режим). **С `limit`** — постраничный режим: ответ — конверт
```json
{"items": [ ... ], "total": 6440, "limit": 50, "offset": 0}
```
| Параметр | Значение |
|---|---|
| `limit` | размер страницы, `1`…`1000` (иначе `400`); включает постраничный режим |
| `offset` | смещение, `>= 0` (без `limit` — `400`) |
| `state` | одно или несколько состояний через запятую (`queued`, `assigning_fip`, `awaiting_self_check`, `checking`, `aggregating`, `done`, `failed`, `occupied`) |
| `q` | подстрока адреса |
| `result` | итог: `pass`, `partial`, `fail`, `cancelled` |
| `order` | `sequence` (по умолчанию, порядок очереди) или `aggregated_at_desc` (последние завершённые) |
`total` — число записей после фильтров. Параметры фильтров без `limit` возвращают отфильтрованный массив.
### `GET /api/v1/admin/ips/{ip}`
Детали по одному адресу: сам объект IP, все проверки текущей попытки и
@@ -445,31 +520,116 @@ YAML для этой секции больше не перечитывается
### `POST /api/v1/admin/ips/scan`
Сканирует текущий проект OpenStack на предмет свободных (не привязанных ни
к одному порту) Floating IP и сразу передаёт найденный список в `POST
/api/v1/admin/ips` — тот же add/requeue/reorder-вызов, как если бы
оператор ввёл эти адреса вручную. Не принимает тело запроса.
Запускает **фоновое** сканирование проекта OpenStack: находит все свободные (не привязанные ни к одному порту) Floating IP
и ставит их в очередь — тот же add/requeue/reorder, что и `POST /api/v1/admin/ips`. Не принимает тело запроса и **сразу отвечает**
`202` со статусом задания; ход сканирования смотрите через `GET /api/v1/admin/ips/scan`.
Почему в фоне: в проекте может быть тысячи Floating IP (на стенде — около 6,4 тыс.), Neutron отдаёт такой список минуты. Control-api читает
его **страницами** (по `openstack.list_page_size`, по умолчанию 200, по `marker`), повторяет страницу при обрыве соединения/5xx/429,
сначала обнаруживает **все** адреса и только потом ставит их в очередь кусками по 500 в порядке возрастания IP. Если чтение не удалось
(после повторов), в очередь не попадает ничего — очередь остаётся как была, а статус задания — `error`.
| Параметр | Значение |
|---|---|
| `dry_run=true` | только найти и посчитать свободные адреса; очередь не меняется (безопасная проверка, итог — в статусе) |
| `wait=true` | дождаться окончания и ответить `200` прежним телом `{scanned_free, added[], requeued[], reordered[], skipped_in_progress[]}` (для curl и скриптов; при ошибке `502`) |
Одновременно идёт одно сканирование: повторный запрос во время работы **присоединяется** к текущему и тоже отвечает `202` с его статусом.
### `GET /api/v1/admin/ips/scan`
Статус и прогресс сканирования (admin-токен).
Ответ (`200`):
```json
{
"scanned_free": 3,
"added": ["203.0.113.20"],
"requeued": [],
"reordered": ["203.0.113.10", "203.0.113.11"],
"skipped_in_progress": []
"state": "listing",
"running": true,
"dry_run": false,
"pages": 12,
"discovered": 2400,
"free": 2399,
"added": 0,
"requeued": 0,
"reordered": 0,
"skipped_in_progress": 0,
"started_at": "2026-10-01T15:47:40.759Z",
"finished_at": null,
"error": ""
}
```
`scanned_free` — сколько свободных Floating IP нашлось в проекте всего
(включая уже стоящие в очереди — они попадут в `reordered`, а не
`added`). Если свободных адресов нет вообще, это не ошибка: ответ будет
`{"scanned_free": 0, "added": [], ...}`.
`state`: `idle` (в этом процессе сканирования ещё не было), `clearing` (очистка очереди — только в автоцикле), `listing` (чтение страниц),
`enqueuing` (постановка в очередь), `done`, `error` (причина в `error`), `cancelled`. `discovered` — сколько Floating IP прочитано
(свободных и занятых), `free` — из них свободных, `added`/`requeued`/`reordered`/`skipped_in_progress` — итог постановки в очередь
(как в `POST /admin/ips`). Статус хранится в памяти процесса: после перезапуска control-api он снова `idle`.
Помимо ручного вызова, сканирование можно включить по расписанию —
`orchestrator.fip_scan_interval_seconds` в `control-api.yaml` (0, по
умолчанию, — только по запросу через эту ручку или кнопку «Сканировать
Floating IP» в дашборде).
Помимо ручного вызова, сканирование можно включить по расписанию — `orchestrator.fip_scan_interval_seconds` в `control-api.yaml`
(0, по умолчанию, — только по запросу через эту ручку или кнопку «Сканировать Floating IP» в дашборде). Пока включён
[автоматический цикл](#автоматический-цикл-проверок), периодический скан не выполняется.
## Автоматический цикл проверок
Опциональный повторяющийся сценарий «очистить очередь → просканировать
Floating IP → дождаться завершения всех проверок → пауза → заново» (описание
для оператора — [USAGE.md](USAGE.md#автоматический-цикл-проверок)). По умолчанию
выключен. Состояние и параметры хранятся в базе; перезапуск control-api их
не сбрасывает.
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| `GET` | `/api/v1/admin/auto-cycle` | — | `200`, статус | — |
| `PUT` | `/api/v1/admin/auto-cycle` | `{"interval_seconds": N, "max_run_seconds": M}` — любое поле можно опустить | `200`, статус | `400` — `interval_seconds < 60` или `max_run_seconds < 0` (ничего не применяется) |
| `POST` | `/api/v1/admin/auto-cycle/start` | — | `200`, статус | — |
| `POST` | `/api/v1/admin/auto-cycle/stop` | — | `200`, статус | — |
Каждый метод отвечает полным объектом статуса:
```json
{
"enabled": true,
"interval_seconds": 3600,
"max_run_seconds": 0,
"phase": "waiting",
"run_started_at": null,
"next_run_at": "2026-10-01T08:00:12.345Z",
"last_run_started_at": "2026-10-01T07:00:01.100Z",
"last_run_finished_at": "2026-10-01T07:00:12.345Z",
"last_outcome": "completed",
"last_error": "",
"last_scanned_free": 12,
"runs_total": 5
}
```
- `phase` — `idle` (выключен или ещё не стартовал), `running` (идут
проверки), `waiting` (пауза до `next_run_at`).
- `last_outcome` — `completed`, `no_free_ips`, `timeout`, `error` или
`stopped`; пустая строка, пока не завершился ни один цикл. Для `error`
причина — в `last_error`.
- `last_scanned_free` — сколько свободных Floating IP нашёл скан последнего
цикла; `runs_total` — сколько циклов завершилось исходом `completed`.
- `max_run_seconds = 0` — без ограничения времени ожидания проверок.
- Времена — RFC 3339 (UTC), `null`, пока не наступили.
`POST .../start` идемпотентен: у уже включённого цикла ничего не меняется
(идущий цикл не перезапускается). Включённый цикл начинает первый прогон на
ближайшем шаге оркестратора (порядка `orchestrator.poll_interval_seconds`).
`POST .../stop` выключает цикл и переводит его в `idle`; проверки, которые
уже идут, не прерываются. Новое значение `interval_seconds` начинает действовать
со следующей паузы.
События цикла (`auto_cycle_started`, `auto_cycle_completed`,
`auto_cycle_timeout`, `auto_cycle_error`, `auto_cycle_stopped`) попадают в
общий журнал событий; шаги цикла записывают обычные `queue_cleared` и
`fip_scan`.
```bash
curl -s -X PUT http://<control-api>:8080/api/v1/admin/auto-cycle \
-d '{"interval_seconds": 7200, "max_run_seconds": 1800}'
curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/start
curl -s http://<control-api>:8080/api/v1/admin/auto-cycle
curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
```
## Реестр адресов и история проверок
@@ -484,7 +644,10 @@ Floating IP» в дашборде).
### `GET /api/v1/admin/registry`
Список всех адресов реестра с краткой сводкой по каждому.
Список адресов реестра с краткой сводкой по каждому. Без параметров — все адреса одним массивом; **с `limit`** (`1`…`1000`) —
постраничный конверт `{"items": [...], "total": N, "limit": L, "offset": O}`, параметры `offset`, `q` (подстрока адреса) и
`last_result` (`pass`/`partial`/`fail`/`cancelled`). Страница и фильтры применяются в SQL до расчёта сводки, поэтому
реестр из тысяч адресов отдаётся за доли секунды.
```json
[
+74 -29
View File
@@ -48,8 +48,8 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
| Страница | Назначение |
|---|---|
| `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. Поиск по IP и фильтр по статусу (`pass`/`partial`/`fail`/`cancelled`) над обеими таблицами — набранное/выбранное не сбрасывается очередным обновлением. |
| `/ips` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). Кнопка «Сканировать Floating IP» делает то же самое автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу ставит их в очередь (`POST /api/v1/admin/ips/scan`, см. [API.md](API.md#post-apiv1adminipsscan)) — то же сканирование можно включить по расписанию через `orchestrator.fip_scan_interval_seconds`. У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно убирает адрес из очереди, но не из реестра — см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. Пока не истекла настроенная на `/settings` пауза (`fip_settle_seconds`), только что привязавший Floating IP адрес показывает отдельный бейдж «прогрев FIP» вместо обычного статуса. Если на момент попытки привязки Floating IP оказался уже занят другим портом (дрейф состояния облака или ошибочно переданный адрес), цикл проверки для него не запускается — адрес показывает отдельный бейдж «занят» (отличный от «fail») и строку `fip_occupied` в списке событий на его странице; кнопка «Перепроверить» ставит его в очередь заново. |
| `/overview` | Сводная статистика: счётчики по состояниям, «текущая проверка» (live-снимок всех IP не в терминальном состоянии) и «последние N завершённых» (по умолчанию 20, `overview.last_completed_count`) с разбивкой pass/partial/fail/cancelled. Обновляется каждые `overview.poll_interval_seconds` секунд без перезагрузки страницы. Поиск по IP и фильтр по статусу (`pass`/`partial`/`fail`/`cancelled`) над обеими таблицами — набранное/выбранное не сбрасывается очередным обновлением. Пока включён [автоматический цикл](USAGE.md#автоматический-цикл-проверок), под счётчиками показывается индикатор «Автоцикл активен» с текущей фазой и временем следующего запуска; управляется цикл на `/settings`. |
| `/ips` | Очередь **постранично** (по 50 адресов; 25/50/100/200) с поиском по IP и фильтром по состоянию/итогу на сервере; кнопка «Сканировать Floating IP» запускает фоновое сканирование с панелью прогресса, «Пробное сканирование» ничего не ставит в очередь (подробности — «Очередь из тысяч адресов» ниже). Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `POST /api/v1/admin/ips` — **один и тот же вызов** добавляет новые адреса и принудительно перезапускает уже завершённые (см. ниже). Кнопка «Сканировать Floating IP» делает то же самое автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу ставит их в очередь (`POST /api/v1/admin/ips/scan`, см. [API.md](API.md#post-apiv1adminipsscan)) — то же сканирование можно включить по расписанию через `orchestrator.fip_scan_interval_seconds`. У каждого адреса — кнопка «Перепроверить» (для `done`/`failed`) или «Отменить» (для активных состояний), и всегда — «Удалить» (безвозвратно убирает адрес из очереди, но не из реестра — см. ниже). Чекбоксы у строк + кнопка «Удалить выбранные» удаляют список одним вызовом; «Очистить всё» удаляет вообще всё, включая активные проверки — обе операции требуют явного подтверждения. Пока не истекла настроенная на `/settings` пауза (`fip_settle_seconds`), только что привязавший Floating IP адрес показывает отдельный бейдж «прогрев FIP» вместо обычного статуса. Если на момент попытки привязки Floating IP оказался уже занят другим портом (дрейф состояния облака или ошибочно переданный адрес), цикл проверки для него не запускается — адрес показывает отдельный бейдж «занят» (отличный от «fail») и строку `fip_occupied` в списке событий на его странице; кнопка «Перепроверить» ставит его в очередь заново. |
| `/ips/{ip}` | Детали одного адреса, пока он в очереди: все проверки текущей попытки и вся история событий, плюс ссылка на полную историю в реестре (см. ниже). |
| `/registry` | **Реестр** — все адреса, когда-либо поставленные на проверку, независимо от того, стоят ли они сейчас в очереди. Переживает удаление адреса из `/ips` и повторное добавление того же адреса позже (см. «Реестр адресов» ниже). Поиск по IP и фильтр по статусу — то же самое, что на `/overview`, плюс отражается в адресной строке (`?q=&status=`), так что отфильтрованную ссылку можно сохранить/переслать. |
| `/registry/{ip}` | Полная сохранённая история проверок одного адреса по всем циклам (не только текущему) — в отличие от `/ips/{ip}`, которая показывает только текущую попытку. |
@@ -57,43 +57,39 @@ admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
| `/check-types` | Типы проверок (`https`/`icmp`/`ssh`/...), включение/выключение, привязка к группам целей. |
| `/settings` | Три формы: `fip_settle_seconds` — пауза (в секундах) между привязкой Floating IP и началом self-check («прогрев» дата-плейна OpenStack, см. [USAGE.md](USAGE.md#пауза-перед-self-check-fip_settle_seconds)); `history_retention_cycles` — сколько последних циклов проверки хранить на адрес в реестре (0 — без ограничения); и типы проверок пробера — TCP-порты (через запятую) + чекбокс ICMP, общие для всех площадок (см. [USAGE.md](USAGE.md#управление-типами-проверок-пробера)). |
| `/settings` | Четыре блока. Первый — панель **«Автоматический цикл»**: статус и фаза, время последнего/следующего запуска, результат последнего цикла, поля «Интервал между циклами (мин)» и «Максимальная длительность проверки (мин, 0 = без лимита)» с кнопкой «Сохранить» и кнопка «Включить»/«Выключить» (показывается та, что сейчас применима). Значения вводятся в минутах (допустимы дробные), в control-api уходят секундами; минимум интервала — 1 минута (`60` с), нарушение приходит предупреждением в баннере. Подробности — [USAGE.md](USAGE.md#автоматический-цикл-проверок), API — [API.md](API.md#автоматический-цикл-проверок). Далее три формы: `fip_settle_seconds` — пауза (в секундах) между привязкой Floating IP и началом self-check («прогрев» дата-плейна OpenStack, см. [USAGE.md](USAGE.md#пауза-перед-self-check-fip_settle_seconds)); `history_retention_cycles` — сколько последних циклов проверки хранить на адрес в реестре (0 — без ограничения); и типы проверок пробера — TCP-порты (через запятую) + чекбокс ICMP, общие для всех площадок (см. [USAGE.md](USAGE.md#управление-типами-проверок-пробера)). |
### «Текущая» и «последняя завершённая» проверка
### «В работе», «в очереди» и «последняя завершённая» проверка
В `control-api` нет понятия «запуска»/«цикла проверки» как отдельной
сущности — есть только общая очередь IP-адресов
(`docs/PLAN_ADMIN_DASHBOARD.md`). Дашборд ничего не меняет в этом
устройстве и не заводит своего состояния:
устройстве и не заводит своего состояния. Очередь может содержать тысячи
адресов, поэтому `/overview` **никогда не загружает её целиком** — на каждое
обновление запрашиваются счётчики и несколько ограниченных списков:
- **Текущая проверка** — все адреса, которые прямо сейчас не в
состоянии `done`/`failed` (`queued`, `assigning_fip`,
`awaiting_self_check`, `checking`, `aggregating`), вычисляется заново на
каждый запрос из `GET /api/v1/admin/status` + `GET /api/v1/admin/ips`.
- **Последняя завершённая проверка** — последние N адресов, перешедших в
`done`/`failed`, отсортированные по `AggregatedAt` по убыванию (не
«последний запуск», а именно скользящее окно последних по времени
завершений).
- **Счётчики** — `GET /api/v1/admin/status` (по состояниям и `results_by_overall`).
- **В работе** — адреса в `assigning_fip`, `awaiting_self_check`, `checking`, `aggregating`
(не более 100; естественный предел — число валидаторов).
- **В очереди: Q** — счётчик `queued` со ссылкой на `/ips?state=queued` и несколько ближайших адресов.
- **Последние N завершённых** — последние N адресов в `done`/`failed` по `AggregatedAt` по убыванию
(не «последний запуск», а скользящее окно последних по времени завершений).
- **Прогресс** в блоке статистики: «Готово D из T (P%) · в работе A · в очереди Q» с полосой и оценкой
оставшегося времени (по скорости последних завершений, когда их не меньше пяти). `occupied` считается
завершённым состоянием.
### Поиск по IP и фильтр по статусу
На `/overview` и `/registry` есть форма из двух полей — поиск по IP
(подстрока, без учёта регистра) и выпадающий список статуса
(`pass`/`partial`/`fail`/`cancelled`). Оба поля работают вместе (И, а не
ИЛИ) и применяются целиком на стороне дашборда — `client.ListIPs`/
`client.ListRegistry` всегда получают от `control-api` полный список,
`internal/httpapi`/`internal/db` про фильтр вообще не знают.
На `/overview`, `/ips` и `/registry` есть поиск по IP (подстрока) и фильтр по статусу/итогу (`pass`/`partial`/`fail`/`cancelled`;
на `/ips` — ещё по состоянию очереди). Поля работают вместе (И, а не ИЛИ). Фильтрация выполняется **на стороне `control-api`**
(параметры `q`, `state`, `result`/`last_result` у `GET /admin/ips` и `GET /admin/registry`), а дашборд получает только нужную страницу, поэтому
фильтр работает быстро при любом размере очереди.
- **`/overview`** — фильтр действует на обе таблицы сразу («Текущая
проверка» и «Последние N завершённых»). Статус — это фильтр по
итоговому результату (`OverallResult`), поэтому выбор конкретного
статуса скрывает «Текущую проверку» целиком: у ещё идущих проверок
результата попросту нет. Панель статистики (счётчики сверху) фильтру не
подчиняется — это агрегаты по всей очереди, а не по видимым строкам.
- **`/registry`** — тот же принцип, но по одной таблице (`LastResult`), и
значения полей отражаются в адресной строке (`?q=&status=`) через
`hx-replace-url` — отфильтрованную ссылку можно сохранить или переслать,
а обновление страницы (F5) сохраняет применённый фильтр.
- **`/overview`** — `q` и статус передаются в списки «В работе» и «Последние N завершённых». Статус — это фильтр по
итоговому результату (`OverallResult`), поэтому выбор конкретного статуса скрывает «В работе» и «В очереди»: у ещё идущих проверок
результата попросту нет. Панель статистики (счётчики сверху) фильтру не подчиняется — это агрегаты по всей очереди, а не по видимым строкам.
- **`/registry` и `/ips`** — таблица постраничная; значения полей и страница отражаются в адресной строке (`?q=&status=&page=`) через
`hx-replace-url` — отфильтрованную ссылку можно сохранить или переслать, а обновление страницы (F5) сохраняет применённый фильтр.
**Раскладка `/overview` сверху вниз**: панель статистики → форма
фильтра → таблицы. Панель статистики и форма фильтра физически лежат
@@ -108,6 +104,13 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
этот порядок и не переносить форму фильтра/панель статистики обратно
внутрь опрашиваемого блока.
Индикатор автоцикла лежит **внутри** панели статистики
(`overview_stats`), поэтому обновляется тем же out-of-band swap'ом без
отдельного механизма и не меняет порядок блоков. Статус цикла
запрашивается у control-api при каждом обновлении; если запрос не удался
(например, control-api старой версии без этой ручки), индикатор просто не
показывается — остальная страница не страдает, баннер ошибки не выводится.
### Добавление адресов и принудительный повтор — один и тот же вызов
Форма на `/ips` всегда бьёт в `POST /api/v1/admin/ips`. Поведение зависит
@@ -150,6 +153,43 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
циклов) остаётся всегда. Подробнее —
[API.md](API.md#реестр-адресов-и-история-проверок).
## Очередь из тысяч адресов
После сканирования проекта в очереди может оказаться несколько тысяч адресов, поэтому тяжёлые страницы работают постранично:
- **`/ips` и `/registry`** — параметры `page` и `per_page` (по умолчанию 50; допустимо 25/50/100/200), «Показано a–b из N» и кнопки ‹ ›.
Поиск (`q`), состояние/итог и размер страницы применяются **на стороне control-api** (`GET /admin/ips?limit=…`, `GET /admin/registry?limit=…`),
поэтому страница весит десятки килобайт независимо от длины очереди. Фильтры и страница отражены в адресной строке.
- **Массовые операции.** Чекбоксы выбирают строки текущей страницы (счётчик «Выбрано на странице: k из P»). Если отмечен заголовок таблицы и записей
больше страницы, появляется ссылка «Выбрать все N по фильтру»: тогда «Перепроверить»/«Удалить» применяются ко **всем** адресам по текущему фильтру
(адреса разрешаются на сервере и отправляются кусками по 500). Подтверждения показывают реальное число: «Удалить ВСЕ 6440 адресов…».
«Очистить всё» очищает очередь целиком одной быстрой операцией.
- **Сканирование.** Кнопка «Сканировать Floating IP» мгновенно возвращает панель прогресса под кнопкой; пока задание идёт, панель сама
обновляется каждые 2 секунды, по окончании опрос прекращается и таблица перезагружается. Во время сканирования кнопки заблокированы; повторное
нажатие присоединяется к идущему заданию. Ошибка (например, OpenStack недоступен) показывается в панели с причиной.
Саму таблицу `/ips` по таймеру по-прежнему не обновляем — она не сбрасывает ввод оператора.
- Долгие операции (`Очистить всё`, массовое удаление/перепроверка) выполняются с увеличенным таймаутом (120 с), остальные запросы к control-api — с `control_api.timeout_seconds`.
## Вход и сессия
Если заданы `ADMIN_DASHBOARD_USERNAME` и `ADMIN_DASHBOARD_PASSWORD`, все страницы, кроме `/login` и `/static/*`, требуют входа.
Не заданы — дашборд открыт, в логе предупреждение `dashboard login is disabled`.
- **Вход:** страница `/login` (логин и пароль единственного администратора). Неверная пара — «Неверный логин или пароль», cookie не выдаётся.
Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как
относительный путь на этом же сайте).
- **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет —
дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (внизу сайдбара) стирает cookie в браузере;
скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд.
- **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер
уходит на страницу входа целиком, а не подставляет её внутрь фрагмента.
- **CSRF:** изменяющие запросы (`POST`/`PUT`/`DELETE`) принимаются, только если `Origin` (или `Referer`) совпадает с хостом дашборда;
токены в формах не нужны. Reverse-proxy, подменяющий заголовок `Host`, получит `403` на изменяющие запросы.
- **Перебор пароля:** 5 неудачных попыток входа с одного IP за 10 минут → `429` с `Retry-After`; пока действует блокировка, отклоняется и верный пароль.
Счётчик считает по адресу TCP-соединения и не доверяет `X-Forwarded-For`, поэтому за reverse-proxy все клиенты окажутся в одной корзине.
- **Токен к control-api.** Дашборд обращается к API с токеном администратора (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`); если он неверен, страницы
показывают баннер с ответом `401` от control-api.
## Конфигурация
См. `configs/admin-dashboard.example.yaml`. Ключевые поля:
@@ -161,6 +201,11 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
на странице обзора.
- `overview.poll_interval_seconds` — как часто браузер опрашивает
`/overview/fragment` для live-обновления.
- `control_api.token_env` — имя переменной окружения с токеном администратора control-api
(по умолчанию `ADMIN_DASHBOARD_CONTROL_API_TOKEN`).
- `auth.username_env`, `auth.password_env`, `auth.session_secret_env` — имена переменных с логином, паролем и ключом подписи сессии
(по умолчанию `ADMIN_DASHBOARD_USERNAME`, `ADMIN_DASHBOARD_PASSWORD`, `ADMIN_DASHBOARD_SESSION_SECRET`); `auth.session_ttl_minutes` — срок сессии
(480). Подробности — [«Вход и сессия»](#вход-и-сессия).
## Отображение ошибок
+8 -4
View File
@@ -39,13 +39,13 @@ flowchart TB
subgraph OP["Оператор"]
CFG["control-api.yaml<br/>(bootstrap пустой БД:<br/>validators, sites, targets,<br/>check_types, ip_addresses)"]
ENV["control-api.env<br/>(OS_AUTH_URL, OS_TOKEN, ...)"]
ADMIN["curl /api/v1/admin/*<br/>(status/ips/validators,<br/>ips submit/cancel,<br/>config CRUD)"]
ADMIN["curl /api/v1/admin/*<br/>(status/ips/validators,<br/>ips submit/cancel,<br/>auto-cycle start/stop,<br/>config CRUD)"]
end
subgraph CAPI["control-api (управляющая машина, 1 экземпляр)"]
HTTP["HTTP API<br/>/api/v1/agents/*<br/>/api/v1/probers/*<br/>/api/v1/admin/*<br/>/healthz"]
ORCH["Оркестратор: Tick раз в<br/>poll_interval_seconds<br/>claim → associate FIP →<br/>ожидание self-check →<br/>checking → aggregate → release<br/>+ lease sweep + heartbeat sweep"]
DB[("SQLite<br/>validators / ip_queue / sites /<br/>target_groups / check_types /<br/>checks / events")]
ORCH["Оркестратор: Tick раз в<br/>poll_interval_seconds<br/>claim → associate FIP →<br/>ожидание self-check →<br/>checking → aggregate → release<br/>+ lease sweep + heartbeat sweep<br/>+ автоцикл (если включён):<br/>очистка → скан FIP → ожидание →<br/>пауза interval_seconds"]
DB[("SQLite<br/>validators / ip_queue / sites /<br/>target_groups / check_types /<br/>checks / events / auto_cycle")]
OSCLIENT["OpenStack-клиент<br/>(mode: mock | real)"]
end
@@ -80,7 +80,11 @@ flowchart TB
работает по таймеру независимо от HTTP-запросов — назначение IP
валидаторам и агрегация результатов не привязаны к конкретному входящему
запросу, читая актуальную конфигурацию из БД на каждом проходе, а не
единожды при старте. `validator-agent` и `prober` — активная сторона: они
единожды при старте. Опциональный автоцикл — часть того же оркестратора:
на каждом тике он читает из таблицы `auto_cycle` флаг `enabled`, интервал и
фазу (`idle`/`running`/`waiting`), поэтому включение, выключение и смена
интервала действуют без перезапуска, а состояние переживает рестарт (см.
[USAGE.md](USAGE.md#автоматический-цикл-проверок)). `validator-agent` и `prober` — активная сторона: они
сами инициируют все HTTP-запросы к control-api (pull-модель), сам
control-api к ним не обращается.
+12
View File
@@ -58,6 +58,18 @@ It will:
5. Poll `GET /api/v1/admin/status` until every configured IP has reached a
terminal state (`done` or `failed`).
6. Print the final `/api/v1/admin/status` and `/api/v1/admin/ips` output.
7. Force a re-check of the finished address via `POST /api/v1/admin/ips`
and wait for it to drain (`attempt_number` advances).
8. Exercise the **automatic cycle** (`/api/v1/admin/auto-cycle`): set the
smallest allowed interval (60s) and a 120s run limit, `start` it, and
wait for the first cycle to finish. The script then asserts that the
outcome is `completed`, the phase is `waiting` with `runs_total=1`, and
that the registry's `total_cycles` for `127.0.0.1` grew (the cycle
cleared the queue, re-scanned the mock floating IP and re-checked it).
The cycle now passes through the background scan (`scanning` phase) before the checks run. Finally it `stop`s the cycle and asserts it is `idle`. The second cycle
(the interval wait) is covered by unit tests, so the script does not
sit through the 60s pause. The script exits non-zero if any assertion
fails.
Expect to see `127.0.0.1` end with `"state":"done"` and
`"overall_result":"pass"` (all egress checks against the stub targets
+47 -5
View File
@@ -287,6 +287,35 @@ cp configs/prober.example.yaml /etc/cloud-ip-validator/prober.yaml
- `control_api_url` — адрес control-api, доступный с площадки (обычно
через интернет — площадки внешние).
### 5. Аутентификация: токены и пароль дашборда
Доступ к API и дашборду защищается секретами из переменных окружения (в YAML значения не хранятся; в `*.yaml` — только *имена*
переменных, и менять их нужно редко). Токены генерируются случайными: `openssl rand -hex 32`. Схема доступа к методам —
[API.md](API.md#аутентификация).
| Где | Переменная | Назначение |
|---|---|---|
| control-api | `CONTROL_API_ADMIN_TOKEN` | токен администратора: закрывает `/api/v1/admin/*` |
| control-api | `CONTROL_API_AGENT_TOKEN` | токен агентов: закрывает запись результатов валидаторов и проберов |
| validator-agent, prober | `CONTROL_API_AGENT_TOKEN` | тот же токен агентов (отправляется как Bearer) |
| admin-dashboard | `ADMIN_DASHBOARD_CONTROL_API_TOKEN` | токен администратора control-api (то же значение, что `CONTROL_API_ADMIN_TOKEN`) |
| admin-dashboard | `ADMIN_DASHBOARD_USERNAME`, `ADMIN_DASHBOARD_PASSWORD` | логин и пароль единственного администратора дашборда |
| admin-dashboard | `ADMIN_DASHBOARD_SESSION_SECRET` | ключ подписи cookie-сессии (случайная строка; без него — случайный на каждый запуск, сессии сбрасываются рестартом) |
- **Пустое значение = защита выключена.** Токен не задан — соответствующий уровень API открыт; логин/пароль не заданы — дашборд открыт. В обоих
случаях в логе при старте — предупреждение. Это сделано для обратной совместимости; на реальном стенде задайте всё.
- systemd: добавьте переменные в `/etc/cloud-ip-validator/<компонент>.env` (подключается `EnvironmentFile=`, файл `chmod 600`). Docker: переменные
из `.env` (см. `.env.example`).
- Ключи `auth.*` и `*_token_env` в YAML меняют только имена переменных; время жизни сессии — `auth.session_ttl_minutes` дашборда (по умолчанию 480).
- Токены и пароль передаются открытым текстом, если перед сервисами нет TLS: публикуйте API и дашборд через reverse-proxy с TLS.
**Порядок включения без простоя** (особенно когда валидаторы и пробер на других машинах):
1. обновите бинарники всех компонентов — токены ещё не заданы, всё работает как раньше;
2. задайте `CONTROL_API_AGENT_TOKEN` на валидаторах и проберах, `ADMIN_DASHBOARD_*` на дашборде и перезапустите их;
3. **последним** задайте `CONTROL_API_ADMIN_TOKEN` и `CONTROL_API_AGENT_TOKEN` на control-api и перезапустите его.
Если включить токен агентов на control-api раньше, чем он появится у валидатора или пробера, их результаты будут получать `401` и проверки не завершатся.
Ротация токена — та же последовательность с новым значением.
## Развёртывание control-api
```bash
@@ -672,10 +701,17 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
| `VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS` | нет | `5` |
`--cap-add NET_RAW` обязателен для ICMP-проверок, как и у `prober`.
`self_check.ip_echo_urls` в переменные не вынесен — при отсутствии в
конфиге агент сам подставляет дефолт (`api.ipify.org`, `ifconfig.me`);
свой список задавайте через смонтированный конфиг вместо шаблона, если
нужно переопределить.
`self_check.ip_echo_urls` и `self_check.methods` в переменные не вынесены —
при отсутствии в конфиге агент сам подставляет дефолты (`api.ipify.org`,
`ifconfig.me` и `methods: [ip_echo]`); свои значения задавайте через
смонтированный конфиг вместо шаблона, если нужно переопределить.
`methods` — способы самопроверки в порядке приоритета (`ip_echo`,
`control_api`), достаточно подтверждения любым. Способ `control_api`
спрашивает у control-api, с какого адреса он видит валидатора
(`GET /agents/{id}/observed-ip`); при внешнем размещении control-api
рекомендуется `[control_api, ip_echo]`. Ограничение: если control-api
достижим из облака по внутренней сети, он увидит приватный адрес
валидатора и этот способ всегда даст несовпадение — используйте `ip_echo`.
### Обновление образов после изменения кода
@@ -703,6 +739,10 @@ docker compose up -d --build
сборке) и `docker rm -f <имя> && docker run ... ` (или `docker restart`,
если менялись только переменные окружения, а не сам бинарник/образ).
Для массового обновления валидаторов (git-клон, сборка образа на хосте,
замена контейнера, проверка регистрации) есть Ansible-сценарий:
[`deploy/ansible/`](../deploy/ansible/README.md).
### Диагностика Docker-развёртывания
- **Контейнер сразу падает, в логах `exec format error`** — образ собран
@@ -775,7 +815,9 @@ curl -s http://<control-api>:8080/api/v1/admin/validators | python3 -m json.tool
через floating IP, который в данный момент привязан к валидатору.
- `validator-agent` → внешние IP-echo сервисы из `self_check.ip_echo_urls`
(по умолчанию `api.ipify.org`, `ifconfig.me`) — **обязательно вне
облака**: это и есть механизм self-check (см.
облака**: это и есть механизм self-check способом `ip_echo` (при
`self_check.methods` с `control_api` достаточно ещё и доступа к control-api
по внешней сети; см.
[DIAGRAMS.md](DIAGRAMS.md#2-поток-данных-от-валидатора-к-целевому-серверу-egress-проверка)).
Если валидатор не может достучаться ни до одного из этих адресов,
self-check никогда не пройдёт и IP будет бесконечно возвращаться в
+158 -6
View File
@@ -7,11 +7,18 @@
[API.md](API.md) — здесь мы используем их только как инструмент, не
углубляясь в протокол.
> **Токен в примерах.** Если на стенде включена аутентификация
> ([SETUP.md](SETUP.md#5-аутентификация-токены-и-пароль-дашборда)), к вызовам
> `/api/v1/admin/*` в примерах `curl` ниже нужно добавлять заголовок
> `-H "Authorization: Bearer $ADMIN_TOKEN"` (значение `CONTROL_API_ADMIN_TOKEN`);
> для краткости он опущен. Дашборд запрашивает логин и пароль.
## Содержание
- [Как устроена работа с системой](#как-устроена-работа-с-системой)
- [Добавление новых IP в очередь](#добавление-новых-ip-в-очередь)
- [Сканирование Floating IP из OpenStack](#сканирование-floating-ip-из-openstack)
- [Автоматический цикл проверок](#автоматический-цикл-проверок)
- [Наблюдение за очередью](#наблюдение-за-очередью)
- [Значения полей IP](#значения-полей-ip)
- [Как читать итоговый результат (pass/partial/fail)](#как-читать-итоговый-результат-passpartialfail)
@@ -83,7 +90,8 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips \
самому найти их в облаке:
```bash
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan # 202: сканирование запущено в фоне
curl -s http://<control-api>:8080/api/v1/admin/ips/scan # ход и результат
```
Сканируются все Floating IP текущего проекта OpenStack, но в очередь
@@ -94,13 +102,133 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/scan
встают в очередь, уже завершённые перезапускаются, активно проверяемые не
трогаются (см. [выше](#добавление-новых-ip-в-очередь)).
В `admin-dashboard` то же самое — кнопка «Сканировать Floating IP» на
странице `/ips`.
**Сколько адресов — не важно.** Сканирование работает в фоне и читает список из OpenStack
**страницами** (по 200 адресов, с повторами при обрывах), поэтому подходит и для проекта с
тысячами Floating IP: на стенде с 6441 адресом чтение занимает около 1,5–2 минут. Сначала
обнаруживаются **все** адреса, и только потом они ставятся в очередь (кусками по 500, в порядке
возрастания IP); сразу после этого начинаются проверки. Если чтение сорвалось даже после повторов,
в очередь не попадает ничего — очередь остаётся как была, а на панели виден `error` и причина.
Одновременно идёт одно сканирование: повторное нажатие присоединяется к текущему.
В `admin-dashboard` — кнопка «Сканировать Floating IP» на странице `/ips`: она сразу отвечает, а под
кнопкой появляется панель прогресса (читаются страницы → ставятся в очередь → готово: прочитано
страниц, найдено, свободных, добавлено, время), по окончании таблица обновляется сама. Рядом —
«Пробное сканирование»: оно проходит все страницы и показывает, сколько свободных адресов нашлось,
**не меняя очередь** (удобно проверить, что облако отвечает и сколько адресов будет поставлено).
Параметры чтения (`control-api.yaml`): `openstack.list_page_size` (200), `openstack.request_timeout_seconds`
(60 — таймаут одного запроса к OpenStack), `openstack.list_page_retries` (5), `orchestrator.fip_scan_timeout_seconds`
(1800 — предел всего сканирования).
Если хочется, чтобы сканирование происходило само по расписанию, а не
только по запросу — задайте `orchestrator.fip_scan_interval_seconds`
(в секундах) в `control-api.yaml`; `0` (по умолчанию) оставляет только
ручной запуск через ручку/кнопку выше.
ручной запуск через ручку/кнопку выше. Пока включён
[автоматический цикл](#автоматический-цикл-проверок), это периодическое
сканирование не выполняется — цикл сам управляет очередью.
> **Сколько займут проверки.** Один адрес занимает около 50 секунд на валидаторе (из них 30 с — пауза
> `fip_settle_seconds`). Поэтому очередь из 6440 адресов — примерно 18 часов на 5 валидаторах,
> 9 часов на 10, 4,5 часа на 20. Ускорить можно числом валидаторов и (осторожно) `fip_settle_seconds`;
> на странице «Обзор» виден прогресс «Готово D из T» и оценка оставшегося времени.
## Автоматический цикл проверок
Опциональный режим, который сам повторяет то, что оператор делает руками:
по умолчанию **выключен**, включается и настраивается администратором.
Один цикл — это пять шагов:
1. Очередь очищается целиком — то же, что кнопка «Очистить всё» (см.
[«Удаление адресов из очереди»](#удаление-адресов-из-очереди)). История
в [реестре](#реестр-адресов-и-глубина-истории) при этом сохраняется.
2. Control-api находит все свободные Floating IP и ставит их в очередь —
то же, что «Сканировать Floating IP» (см.
[выше](#сканирование-floating-ip-из-openstack)). Шаги 1–2 выполняются одним
фоновым заданием, поэтому долгое чтение тысяч адресов не блокирует работу
оркестратора (назначение валидаторов, лизинги, heartbeat).
3. Проверки запускаются сами — как для любого адреса в очереди.
4. Цикл ждёт, пока **все** адреса очереди дойдут до конечного состояния
(`done`, `failed` или `occupied`). К этому моменту результат каждого
адреса уже записан в реестр.
5. Выдерживается пауза `interval_seconds`, после чего цикл начинается заново
с шага 1. Пауза отсчитывается от **завершения** предыдущего цикла, а не
от его начала.
### Параметры
| Параметр | По умолчанию | Смысл |
|---|---|---|
| `interval_seconds` | `3600` (1 час) | Пауза между циклами. Не меньше `60`: слишком частые сканы нагружают API OpenStack. |
| `max_run_seconds` | `0` (без лимита) | Сколько максимум ждать на шаге 4 (отсчёт — **от конца сканирования**). По истечении цикл фиксирует `timeout` и переходит к паузе — защита от зависания (нет свободных валидаторов, недоступна площадка). Очередь при этом не трогается: следующий цикл её очистит, а до тех пор видно, что именно не дошло до конца. **При тысячах адресов оставьте `0`** (или задайте больше расчётного времени: 6440 адресов — часы). |
Параметры хранятся в базе и меняются на лету, без перезапуска; в `control-api.yaml`
ничего задавать не нужно. Новый `interval_seconds` применяется к паузе
**со следующего цикла** — уже идущая пауза досчитывается по старому значению.
### Управление
Через API (подробности — в [API.md](API.md#автоматический-цикл-проверок)):
```bash
# задать параметры (любое из полей можно опустить)
curl -s -X PUT http://<control-api>:8080/api/v1/admin/auto-cycle \
-d '{"interval_seconds": 7200, "max_run_seconds": 1800}'
# включить: первый цикл начнётся сразу
curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/start
# выключить
curl -s -X POST http://<control-api>:8080/api/v1/admin/auto-cycle/stop
# посмотреть состояние
curl -s http://<control-api>:8080/api/v1/admin/auto-cycle
```
В `admin-dashboard` — панель «Автоматический цикл» на странице `/settings`: поля
«Интервал между циклами» и «Максимальная длительность проверки» (в минутах),
кнопки «Включить»/«Выключить». Пока автоцикл включён, на странице `/overview`
в блоке статистики показывается индикатор «Автоцикл активен» с фазой и временем
следующего запуска.
### Фазы и результат последнего цикла
`phase` показывает, что происходит сейчас: `idle` (автоцикл выключен или ещё не
стартовал), `scanning` (шаги 1–2: очистка очереди и чтение/постановка Floating IP),
`running` (идут проверки — шаги 3–4) и `waiting` (пауза между циклами,
шаг 5; время следующего запуска — `next_run_at`). Результат последнего цикла
(`last_outcome`):
| Значение | Что произошло |
|---|---|
| `completed` | Все адреса дошли до конечного состояния; `runs_total` растёт на 1. |
| `no_free_ips` | Сканирование не нашло свободных Floating IP — ждать нечего, цикл сразу ушёл в паузу. |
| `timeout` | Проверки не уложились в `max_run_seconds`. |
| `error` | Не удалось очистить очередь или просканировать облако; причина — в `last_error`. Повтор — через `interval_seconds`. |
| `stopped` | Автоцикл выключили в момент, когда шёл цикл. Выключение в паузе предыдущий результат не затирает. |
### Что важно знать
- **Выключение не прерывает проверки**, которые уже идут: они закончатся и попадут
в реестр, остановится только повторение. Если выключить цикл во время фазы `scanning`,
сканирование отменяется (уже поставленные в очередь куски остаются).
- Автоцикл **владеет очередью**: каждый цикл начинается с её полной очистки,
поэтому адреса, добавленные вручную, будут удалены (их история в реестре
остаётся). Ручные «Очистить всё» и «Сканировать Floating IP» во время цикла
не ломают его: если очередь опустела, цикл считается завершённым.
- Состояние хранится в базе и **переживает перезапуск** control-api: идущий цикл
продолжит ждать, а пауза — досчитается до прежнего `next_run_at`.
- События цикла (`auto_cycle_started`, `auto_cycle_completed`, `auto_cycle_timeout`,
`auto_cycle_error`, `auto_cycle_stopped`) пишутся в журнал событий вместе с
`queue_cleared` и `fip_scan`.
- Если в момент старта цикла уже идёт чужое сканирование (ручное, пробное или по расписанию),
цикл **дожидается** его окончания и запускает собственное (с очисткой очереди) — присоединяться
к чужому нельзя: оно могло ничего не поставить в очередь.
- Если сканирование завершилось ошибкой, очередь уже очищена (шаг 1) и остаётся пустой до следующего
цикла (`interval_seconds`); исход цикла — `error` с причиной в `last_error`.
- Цикл на тысячах адресов длится часы; пауза `interval_seconds` отсчитывается после его завершения.
- В реальном OpenStack отвязка Floating IP после очистки очереди может
отразиться с задержкой; если скан сразу после неё не увидел свободных адресов,
цикл завершится с `no_free_ips` и повторится через `interval_seconds`.
## Наблюдение за очередью
@@ -280,6 +408,17 @@ curl -s http://<control-api>:8080/api/v1/admin/validators | python3 -m json.tool
(занят), `unreachable` (пропустил heartbeat дольше
`orchestrator.heartbeat_timeout_seconds`).
Правила, которые держат состояние валидатора согласованным:
- валидатор держит **не более одного адреса**; адрес освобождает валидатор
только пока он остаётся его текущим (запоздалое завершение старого адреса
чужого валидатора не освобождает);
- после пропущенного heartbeat валидатор, у которого есть адрес, возвращается
в `assigned`, а не в `idle`, и не получает второй адрес; без адреса — в `idle`;
- `unreachable`-валидатор не получает адресов, пока не пришлёт heartbeat
(даже если лизинг его адреса истёк и адрес вернулся в очередь);
- на каждом такте оркестратор сверяет валидаторы с очередью и исправляет
расхождения (в логе `repaired validators that disagreed with the queue`).
**Добавление нового валидатора (без перезапуска control-api):**
1. Поднимите новую ВМ в сервисном проекте облака, узнайте её Neutron
`port_id`.
@@ -562,6 +701,12 @@ curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/delete \
curl -s -X POST http://<control-api>:8080/api/v1/admin/ips/clear
```
Очистка отвязывает Floating IP только у адресов, которые ещё в работе
(завершённые уже свободны), затем сама опрашивает порты валидаторов в облаке и
снимает оставшиеся привязки адресов из реестра. Занимает секунды. Она не
прерывается разрывом соединения (таймаутом клиента или дашборда): операция
доводится до конца на стороне control-api, предел — 10 минут.
В `admin-dashboard` то же самое доступно на странице `/ips`: чекбоксы у
каждой строки + кнопка «Удалить выбранные» для точечного/массового
удаления, кнопка «Удалить» в каждой строке, и отдельная кнопка «Очистить
@@ -614,7 +759,9 @@ curl -s -X PUT http://<control-api>:8080/api/v1/admin/config/orchestrator \
дело обычно в self-check: он запрашивает внешние (вне облака) сервисы из
`self_check.ip_echo_urls` в конфиге валидатора (по умолчанию
`api.ipify.org`, `ifconfig.me`) — если у ВМ-валидатора нет исходящего
доступа в интернет к этим адресам, запрос не проходит вообще, и агент
доступа в интернет к этим адресам, запрос не проходит вообще (при
`self_check.methods: [control_api, ip_echo]` агент сперва спросит адрес у
control-api, и проверка может пройти и без IP-echo), и агент
даже не может *сообщить* результат control-api (ни успешный, ни
неуспешный) — тогда статус реально зависает до истечения
`orchestrator.lease_ttl_seconds`, после чего адрес возвращается в
@@ -635,7 +782,12 @@ https://api.ipify.org`) и логи `journalctl -u validator-agent` на пре
облака (см. `self_check.ip_echo_urls`) — запрос к чему-либо внутри
проекта (в том числе к самому control-api, если он в той же внутренней
сети) покажет приватный адрес валидатора независимо от того, правильно
ли привязан FIP, и всегда будет давать ложный провал.
ли привязан FIP, и всегда будет давать ложный провал. Это относится и к
способу `control_api` (`self_check.methods`): он корректен только когда
валидатор ходит к control-api через внешнюю сеть; при внутреннем доступе в
`detail` будет подсказка про приватный адрес — оставьте `ip_echo`. В
`detail` события `self_check_result` указан сработавший способ
(`matched (control_api)`) либо причина по каждому способу.
**Площадка (`site-N`) никогда не отчитывается по конкретному IP.**
Сперва проверьте статус самой площадки — `GET
@@ -0,0 +1,131 @@
# План: аутентификация API и UI
> Дата: 2026-10-01 11:12 MSK · Статус: **реализовано** — результаты ревью и тестов: [2026-10-01_11-31_authentication-review.md](2026-10-01_11-31_authentication-review.md)
## Context
Сейчас ни `control-api`, ни `admin-dashboard` не имеют аутентификации (прямо сказано в `docs/API.md`, раздел «Важно»):
любой, кто достучится до порта, может менять очередь и конфигурацию, а в дашборд зайти без пароля. Нужно:
1. закрыть **ручки управления** (`/api/v1/admin/*`) токеном администратора;
2. оставить `validator-agent` и `prober` возможность **забрать свою настройку/задание без аутентификации**, но не дать
подделывать результаты проверок — записывающие вызовы закрыть **отдельным токеном агентов** (без срока жизни);
3. закрыть UI **логином и паролем** (сессия по cookie).
Решения пользователя: вариант «открыты только получение настройки/задания», токен агентов отдельный от админского и
бессрочный (статический, из env); `control-api` без токена **стартует с предупреждением** в логе (обратная совместимость);
пароль администратора дашборда — в env, сравнение в константное время.
## Карта кодовой базы (по графу, сверено с кодом)
Единые точки врезки — их мало, поэтому изменения локальны:
- `httpapi.Server.Handler()` (`internal/httpapi/server.go:29`) — один `mux` + `loggingMiddleware`; все 45 маршрутов в
`internal/httpapi/routes.go`: 33 admin, 7 agents, 4 probers, `GET /healthz`.
- `apiclient.Client.Do` (`internal/apiclient/apiclient.go`) — единственный HTTP-клиент `agentcore` (`internal/agentcore/agentcore.go`)
и `probercore` (`internal/probercore/probercore.go`) к control-api. Запросы к внешним целям/IP-echo идут через
`http.DefaultClient`/`checkrunner` — токен туда попасть **не должен**.
- `dashboard.client.do` (`internal/dashboard/client.go:48`) — единственный клиент дашборда к control-api (не `apiclient`).
- `dashboard.Server.Handler()` (`internal/dashboard/server.go:43`) — один `mux` + `loggingMiddleware`; маршруты в
`internal/dashboard/routes.go`; статика `GET /static/`; htmx опрашивает `/overview/fragment` каждые N секунд.
- Конфиг: `internal/config/config.go` (`ControlAPI`, `AdminDashboard`/`DashboardControlAPIConfig`, агент/пробер),
`Load*` с дефолтами «if x == 0». Секреты уже передаются **именами env-переменных** (`openstack.*_env`) — тот же приём.
- `go.mod` без `x/crypto`; `crypto/subtle`, `crypto/hmac`, `crypto/sha256` — из стандартной библиотеки, новых зависимостей нет.
## Классификация маршрутов (45)
| Доступ | Маршруты |
|---|---|
| **admin-токен** (33) | все `/api/v1/admin/*`: status, ips (+scan/clear/delete/cancel/{ip}), validators, registry, auto-cycle ×4, config/* |
| **agent-токен** (5) | `POST /agents/{id}/self-check`, `/events`, `/results`, `/complete`; `POST /probers/{site_id}/results` |
| **открыто** (7) | `GET /healthz`; `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`; `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments` |
Токены разные: admin-токен **не** открывает agent-маршруты и наоборот. Heartbeat оставлен открытым, как в выбранном варианте
(риск: подделка heartbeat «оживляет» упавший валидатор; при желании переносится под agent-токен одной строкой в таблице).
## Дизайн
### 1. control-api (`internal/httpapi`, `internal/config`, `cmd/control-api`)
- **Таблица маршрутов с обязательным уровнем доступа.** `routes.go` переписывается на таблицу
`[]route{pattern, handler, access}` (`accessOpen|accessAgent|accessAdmin`) — поле обязательное, забыть защитить новый
маршрут нельзя; одна и та же таблица используется в тесте покрытия.
- `internal/httpapi/auth.go`: `Authenticator{AdminToken, AgentToken string}`; `require(access, h)` читает
`Authorization: Bearer …`, сравнивает через `sha256` + `subtle.ConstantTimeCompare`; при несовпадении — `401`
`{"error":"unauthorized"}` + `WWW-Authenticate: Bearer`, в лог — метод/путь/remote без токена. Пустой токен соответствующего
уровня ⇒ проверка этого уровня отключена (совместимость, как выбрал пользователь).
- `Server` получает поле `Auth`, выставляемое методом `WithAuth(admin, agent)`; `httpapi.New(...)` не меняется ⇒ существующие
тесты и `newConfigTestHarness` работают без токенов.
- Конфиг: секция `auth:` в `control-api.yaml` — `admin_token_env` (по умолчанию `CONTROL_API_ADMIN_TOKEN`) и
`agent_token_env` (`CONTROL_API_AGENT_TOKEN`); значения читаются из env в `run()`, в YAML секретов нет.
- `cmd/control-api/main.go`: при пустом токене — `log.Warn("admin API is open: CONTROL_API_ADMIN_TOKEN is not set")`
(аналогично для agent); значения токенов в логи не попадают.
### 2. validator-agent и prober (`internal/apiclient`, `internal/agentcore`, `internal/probercore`, `internal/config`)
- `apiclient.Client` получает поле `Token`; `Do` добавляет `Authorization: Bearer <token>` ко **всем** запросам к control-api
(на открытых маршрутах он безвреден). `apiclient.New` не меняется (поле выставляется отдельно).
- Конфиги агента/пробера: `control_api_token_env` (по умолчанию `CONTROL_API_AGENT_TOKEN`); читается в `cmd/*/main.go`.
Токен не попадает в `fetchIPEcho`/проверки (они используют отдельные клиенты).
- Агент и пробер продолжают без токена регистрироваться и получать задания; результаты без токена получат `401` и будут
залогированы существующим кодом обработки ошибок.
### 3. admin-dashboard (`internal/dashboard`, `internal/config`, `cmd/admin-dashboard`)
- **Токен к control-api:** `control_api.token_env` (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`); `client.do` добавляет Bearer.
Ответ `401/403` от API отображается существующим баннером (`apiErr`, `bannerFor`).
- **Логин и пароль:** секция `auth:` — `username_env`, `password_env`, `session_secret_env`, `session_ttl_minutes` (480).
Учётные данные сравниваются через `sha256` + `subtle.ConstantTimeCompare`. Если логин/пароль не заданы — вход не требуется,
в лог предупреждение (как политика control-api).
- `internal/dashboard/auth.go`: middleware вокруг `mux` в `Server.Handler()`:
- открыто: `GET|POST /login`, `GET /static/*`; всё остальное требует валидной сессии;
- без сессии: обычный запрос → `303 /login?next=…`; **htmx-запрос** (`HX-Request`) → `401` + `HX-Redirect: /login`, чтобы
опрос `/overview/fragment` не подставлял страницу входа внутрь фрагмента;
- **сессия без состояния** (дашборд остаётся stateless): cookie `session = base64(payload).hmac`, payload `{user, exp}`,
HMAC-SHA256 ключом из `session_secret_env` (если не задан — случайный на старте + предупреждение: сессии сбрасываются
рестартом); `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS (`X-Forwarded-Proto`/TLS);
- **CSRF** для `POST/PUT/DELETE`: проверка `Origin`/`Referer` на совпадение с `Host` (+ `SameSite=Strict`); токены в
шаблонах не нужны — htmx-формы не меняются;
- **защита от перебора**: счётчик неудачных входов по IP в памяти (5 за 10 минут → `429` с `Retry-After`);
- `POST /logout` — стирает cookie; пункт «Выйти» и имя пользователя в `sidebar_nav` (`templates/layout.html`).
- `templates/login.html` — страница входа в стиле существующих (`html_head`, `.panel`, `.field`, `btn-primary`), баннер ошибки
«Неверный логин или пароль». `loggingMiddleware` не логирует cookie и тело формы.
### 4. Развёртывание и конфигурация
- Примеры: `configs/control-api.example.yaml` (`auth`), `admin-dashboard.example.yaml`, `validator-agent.example.yaml`,
`prober.example.yaml`; копии в `rxprod-compose/sources/` и `deploy/docker/control-api/control-api.docker.example.yaml`.
- Docker: `docker-entrypoint.sh` + `*.yaml.tmpl` дашборда/агента/пробера (whitelist `envsubst` + новые переменные),
`deploy/docker/docker-compose*.yml`, `.env.example`/`.env.prod.example`, `RUN.txt`; `rxprod-compose/docker-compose.yml`
(прокидывает env; реальные секреты — в gitignored `.env`).
- systemd: `EnvironmentFile=` для `validator-agent`, `prober`, `admin-dashboard` (сейчас только у `control-api`).
- Порядок раскатки без простоя: (1) обновить все бинарники — токены не заданы, API открыт; (2) выдать токены агентам, пробером
и дашборду; (3) последним задать токены в `control-api` и перезапустить. Генерация: `openssl rand -hex 32`.
- Токены по HTTP передаются открытым текстом — в документации рекомендация TLS на reverse-proxy; ротация = смена env + рестарт.
### 5. Тесты (plain `testing`, стиль существующих)
- `internal/httpapi`: тест по **той же таблице маршрутов** — для каждого маршрута матрица {без токена, чужой токен, верный}
× уровни; проверка, что admin-токен не открывает agent-маршруты и наоборот; открытые маршруты работают без токена;
`/healthz` открыт; пустые токены ⇒ всё открыто; сквозной `TestEndToEndHTTPFlow` с токенами.
- `internal/apiclient`: заголовок Bearer ставится, без токена — нет; `agentcore`/`probercore`: токен уходит только в control-api.
- `internal/dashboard`: редирект на `/login`; `HX-Redirect` для htmx; вход верный/неверный; cookie-флаги; подделанная и
просроченная cookie; выход; CSRF по `Origin`; throttle; `/static/*` открыт; токен уходит в control-api (fake API проверяет
заголовок); при незаданных учётных данных вход не требуется.
- `scripts/run-local-e2e.sh`: токены для control-api/агента/пробера; проверки: admin-ручка без токена → `401`, с токеном → `200`;
`POST …/results` без токена → `401`; `register`/`assignment` без токена работают; весь прогон проходит с токенами.
### 6. Документация
`docs/API.md` (заменить блок «Важно»: схема токенов, таблица доступа по маршрутам, `401`), `docs/SETUP.md` (переменные, генерация
токенов, порядок раскатки), `docs/USAGE.md` (в curl-примерах — заголовок `Authorization`, общая пометка в начале), `docs/DASHBOARD.md`
(вход, сессия, выход, ключи `auth.*`), `docs/DIAGRAMS.md` (границы доступа), `docs/LOCAL_E2E.md`, `README.md` (разделы
«Конфигурация» и «Безопасность»). После реализации — пересборка `bin/` + `SHA256SUMS` и обновление графа graphify.
## Допущения (проверьте при утверждении)
- **Один общий токен агентов** для `validator-agent` и `prober` (отличный от admin-токена). Если нужны раздельные токены для
валидаторов и проберов — добавляется второй уровень доступа в ту же таблицу.
- Политика «стартовать с предупреждением» распространена и на дашборд (без заданных логина/пароля вход не требуется).
Строгий режим (отказ стартовать) — отдельное небольшое изменение.
- Один пользователь-администратор дашборда; многопользовательность и роли не вводятся.
## Верификация
1. `go build ./... && go vet ./... && go test ./...` и `go test -race` для `httpapi`, `dashboard`, `apiclient`.
2. `scripts/run-local-e2e.sh` с токенами: `401` без токена на admin- и agent-write-ручках, штатный проход цикла с токенами.
3. Вручную (curl): `GET /api/v1/admin/status` без токена → `401`, с `Bearer $ADMIN` → `200`; `POST /agents/{id}/results` с admin-токеном
→ `401`; `GET /agents/{id}/assignment` без токена → `200/204`.
4. Вручную (браузер): дашборд редиректит на `/login`; неверный пароль → ошибка, 5 неудач → `429`; после входа «Обзор» обновляется
без перезагрузки; «Выйти» возвращает на `/login`; через 8 часов (или `session_ttl_minutes`) сессия истекает.
5. Раскатка по шагам из раздела 4 на стенде `rxprod-compose`: сервисы не теряют регистрацию в момент включения токенов.
@@ -0,0 +1,71 @@
# Ревью и тестирование: аутентификация API и UI
> Дата: 2026-10-01 11:31 MSK · План: [2026-10-01_11-12_authentication-plan.md](2026-10-01_11-12_authentication-plan.md)
> Статус: **реализовано и проверено на живом окружении**; токен агентов на живом стенде **намеренно не включён** (см. «Состояние живого стенда»).
## Итог
Реализовано по плану: ручки управления закрыты токеном администратора, записывающие вызовы валидатора и пробера — отдельным
бессрочным токеном агентов, получение настройки и задания остаётся открытым, дашборд закрыт логином и паролем (сессия в подписанной cookie).
Код написан отдельным агентом (Sonnet 5.5), ревью и все проверки выполнены независимо (Sonnet 5.5, high).
Найден и исправлен один реальный дефект (вёрстка страницы входа); остальные замечания — ограничения дизайна, перечислены ниже.
## Что реализовано
| Область | Изменения |
|---|---|
| control-api | `internal/httpapi/routes.go` — таблица маршрутов `{pattern, handler, access}` с **обязательным** уровнем доступа (33 admin, 5 agent, 7 open); `internal/httpapi/auth.go` — `Authenticator`, Bearer, сравнение `sha256` + `subtle.ConstantTimeCompare`, `401` + `WWW-Authenticate: Bearer`, отказы пишутся в лог без токена; `Server.WithAuth`; `config.auth.{admin_token_env,agent_token_env}` |
| validator-agent, prober | `apiclient.Client.Token` — Bearer на каждом запросе к control-api; `WithToken` в `agentcore`/`probercore`; `control_api_token_env`. Токен не попадает в IP-echo и проверки |
| admin-dashboard | `internal/dashboard/auth.go` — вход по логину/паролю, сессия без состояния (HMAC-SHA256), CSRF по `Origin`/`Referer`, защита от перебора, `HX-Redirect` для htmx, защита от open-redirect в `next`; `templates/login.html`; «Выйти» и имя пользователя в сайдбаре; Bearer к control-api в `client.do`; индикатор — только при включённой аутентификации |
| Развёртывание | переменные в `deploy/docker/*`, `rxprod-compose/docker-compose.yml`, `.env*.example`, `RUN.txt`; `EnvironmentFile=` в 4 systemd-юнитах; ключи в `configs/*.example.yaml` и копиях `rxprod-compose/sources/` |
| Тесты | `internal/httpapi/auth_test.go` (матрица по таблице маршрутов), `internal/apiclient/apiclient_test.go`, токены в `agentcore`/`probercore`, `internal/dashboard/auth_test.go` (16 тестов); `scripts/run-local-e2e.sh` — токены и явные проверки 401/open |
Новых зависимостей нет (`crypto/*` стандартной библиотеки). Режим по умолчанию — обратная совместимость: пустой токен ⇒ соответствующий уровень открыт, в логе предупреждение.
## Результаты проверок
| Проверка | Результат |
|---|---|
| `gofmt`, `go build ./...`, `go vet ./...` | чисто |
| `go test ./...` | все пакеты зелёные |
| `go test -race` (httpapi, dashboard, apiclient, agentcore, probercore) | зелёные |
| `scripts/run-local-e2e.sh` с токенами | exit 0: 5 проверок доступа ok, реальные агент и пробер с токеном довели адрес до `pass`, автоцикл ok |
| Живые контейнеры `cloud-ip-validator-*`, `curl` | **40/40** (control-api 401/200, открытые маршруты, heartbeat внешних валидаторов продолжается; дашборд: редиректы, `HX-Redirect`, CSRF, cookie-флаги, open-redirect, tampered cookie, выход) |
| Живой дашборд в настоящем Chromium (Playwright) | **15/15**: вход/ошибка пароля, фоновый htmx-опрос с сессией, htmx `PUT /settings` и `PUT /settings/auto-cycle` (Origin-проверка проходит), выход, нет JS-ошибок |
| Изолированный стенд `civ-authtest-*` (mock, отдельная сеть, удалён после прогона) | токен агентов: `401` без токена / с токеном администратора / с чужим, принят с токеном агентов; реальный агент **без токена** регистрируется и шлёт heartbeat (открытые маршруты), а его записи отклоняются (41 отказ в логе); **с токеном** новых отказов нет, self-check уходит; пробер с токеном регистрируется; перебор: 6-я неверная попытка → `429`, `Retry-After` 600 с, верный пароль в блокировке тоже `429`; секреты в логах не найдены |
Примечание: 4 «FAIL» в выводе скрипта изолированного стенда — ошибка форматирования самого скрипта (сравнивалось `yes` со строкой `yes (…)`);
значения в скобках подтверждают успех (HTTP 200, 41 отказ, 3 IP, `Retry-After` 600). Реальных провалов нет.
## Замечания ревью
| № | Серьёзность | Замечание | Статус |
|---|---|---|---|
| 1 | средняя | **Страница входа: сломана вёрстка** — `.field` имеет `flex: 1 1 220px`, в колонке это давало 220px пустоты между полями; поле пароля не попадало в правило стилей `input[type=…]` и выглядело нестилизованным. Найдено по скриншоту на живом стенде | **исправлено** (`dashboard.css`: `input[type="password"]` в общее правило, `.login-card .field { flex: 0 0 auto }`), перепроверено скриншотом |
| 2 | средняя | Счётчик перебора ключуется по `RemoteAddr` и **не доверяет** `X-Forwarded-For`. За reverse-proxy все клиенты разделят одну корзину: 5 неверных попыток заблокируют вход всем (DoS на админа). Сейчас не проявляется — дашборд открыт напрямую на `:8091`, Caddy перед ним нет | принято; при публикации через прокси нужен список доверенных прокси |
| 3 | низкая | Сессия без состояния: «Выйти» стирает cookie в браузере, но украденная копия остаётся валидной до истечения (`session_ttl_minutes`); смена пароля сессии не отзывает. Инвалидация всех сессий — смена `ADMIN_DASHBOARD_SESSION_SECRET` и рестарт | принято, описано в документации |
| 4 | низкая | На защищённых страницах нет `Cache-Control: no-store` (кнопка «Назад» после выхода может показать кэш) | не исправлено |
| 5 | низкая | `register` и `heartbeat` открыты (решение пользователя): подделка heartbeat «оживляет» упавший валидатор. Перенос под токен агентов — одна строка в таблице маршрутов | принято по решению пользователя |
| 6 | низкая | Токены и пароль передаются по HTTP открытым текстом, если TLS не терминируется перед сервисами | описано в документации (TLS на reverse-proxy) |
| 7 | инфо | `withAuthInfo` заполняет `PageData` через `reflect` — работает, но хрупко при смене структур | не блокирует |
| 8 | инфо | Не добавлен вариант `TestEndToEndHTTPFlow` с токенами — покрыто матрицей по таблице маршрутов и e2e-скриптом | принято |
| 9 | инфо | CSRF-проверка сверяет `Origin` с `Host`: прокси, подменяющий `Host`, получит `403` на POST/PUT | описано в документации |
## Состояние живого стенда (`rxprod-compose`, проект `cloud-ip-validator`)
- **Включено:** токен администратора на control-api (`CONTROL_API_ADMIN_TOKEN`), тот же токен у дашборда (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`),
вход в дашборд (`ADMIN_DASHBOARD_USERNAME` / `ADMIN_DASHBOARD_PASSWORD`), ключ сессии (`ADMIN_DASHBOARD_SESSION_SECRET`).
Значения — в `rxprod-compose/.env` (gitignored; права ужесточены до `600`). Дашборд: `http://<хост>:8091/`, логин `admin`.
- **Не включено намеренно:** `CONTROL_API_AGENT_TOKEN`. На стенде работают внешние компоненты со старыми бинарниками — валидаторы
`validator-1`/`validator-2` (облачные ВМ) и внешний пробер `rxyc`: после включения токена агентов их результаты начнут получать `401`.
Пока записывающие вызовы агентов открыты (в логе control-api — предупреждение `agent write API is open`).
- **Раскатка токена агентов:** (1) обновить бинарники на валидаторах и внешних проберах; (2) задать им `CONTROL_API_AGENT_TOKEN`
(`openssl rand -hex 32`) и перезапустить; (3) последним добавить тот же токен в `.env` стенда (control-api и prober) и выполнить `docker compose up -d`.
- Образы `civ-capi`, `civ-adash`, `civ-prober`, `civ-agent` пересобраны; предыдущие сохранены под тегом `:pre-auth`
(откат: `docker tag civ-capi:pre-auth civ-capi:latest` и `docker compose up -d`). Бэкап БД и прежнего `.env` — в каталоге scratchpad сессии (`/tmp`, временный).
- `bin/` пересобран (`CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"`), `bin/SHA256SUMS` обновлён.
## Что осталось
- Раскатка токена агентов на внешние компоненты (см. выше).
- По желанию: `Cache-Control: no-store` (замечание 4), список доверенных прокси для счётчика перебора (замечание 2).
@@ -0,0 +1,166 @@
# План: сканирование Floating IP и автоцикл при тысячах адресов
> Дата: 2026-10-01 18:19 MSK · Статус: **реализовано** — результаты ревью и тестов: [2026-10-01_18-59_fip-scan-at-scale-review.md](2026-10-01_18-59_fip-scan-at-scale-review.md)
## Context
Нажатие «Сканировать Floating IP» на живом стенде падает: `control-api недоступен: … context deadline exceeded`. Расследование
(2026-10-01) показало: в проекте OpenStack **6441 Floating IP, 6440 свободны** (раньше было 5). `ListFloatingIPs` запрашивает весь
список одним запросом без `limit` и без таймаута, Neutron отвечает >60 с (постранично: 200 адресов ≈ 2,4 с, весь список ≈ 70–80 с),
а дашборд ждёт 10 с. Запрос к тому же привязан к `r.Context()`: при обрыве соединения скан отменяется и не может завершиться.
Цель пользователя прежняя: **одной кнопкой подключить к проверке все доступные в проекте адреса, даже если их тысячи**, после чего
проверка запускается автоматически; автоматический цикл (очистка → скан → проверка → пауза) должен работать в этих условиях.
Решение пользователя по объёму: **полная адаптация** — фоновый постраничный скан + автоцикл + постраничные страницы дашборда.
Ожидание по времени (оценка по реальным данным стенда: слот на адрес ≈ 50 с, из них 30 с — `fip_settle_seconds`):
6440 адресов ≈ 18 ч на 5 валидаторах, ≈ 9 ч на 10, ≈ 4,5 ч на 20. Это ограничение пропускной способности, а не кода; рычаги —
число валидаторов и `fip_settle_seconds`. Для автоцикла это значит: `max_run_seconds` должен быть `0` (без лимита) или > 20 ч.
## Карта кода (по графу и разведке)
- `openstack.FloatingIPClient` (`internal/openstack/interface.go`): `GetFloatingIPByAddress`, `ListFloatingIPs`, `Associate…`, `Disassociate…`;
реализации — реальный `Client` (`client.go`, **нет таймаутов и ретраев**) и `MockClient` (`mock.go`, есть `ListFailure`, нет пагинации).
- `Orchestrator.ScanFloatingIPs` (`internal/orchestrator/orchestrator.go:412`) — синхронный: `OS.ListFloatingIPs` → фильтр `PortID==""` →
один `DB.SubmitIPs`. Вызывается из `handleAdminScanFloatingIPs` (`internal/httpapi/handlers_admin.go:107`, на `r.Context()`),
из `autoCycleStartRun` (`autocycle.go`, **в горутине цикла оркестратора под `autoCycleMu`** — минутный скан остановит `Tick`
и sweeps) и из периодического `scanTickerC` (`cmd/control-api/main.go`).
- БД: SQLite, `SetMaxOpenConns(1)`; `SubmitIPs` — одна транзакция, ~6 запросов на новый адрес (6440 ≈ 38 тыс. запросов);
`DeleteIPs`/`ClearQueue` — одна транзакция, ~6 запросов на адрес; `ListRegistry` — N+1 и **O(n²)**: нет индекса `ip_queue(registry_id)`.
- Full-table загрузчики: `GET /admin/ips`, `GET /admin/status` (грузит все строки ради счёта), `GET /admin/registry`, `autoCycleCheckRun`
(`ListIPs` каждый тик), дашборд `/overview` (опрос каждые 5 с: ~3 МБ JSON и таблица «текущая проверка» из ~6000 `queued`-строк),
`/ips` (~8 МБ HTML), `/registry`. Пагинации и фильтров на сервере нет.
- Дашборд: `client.do` с таймаутом 10 с; фрагменты и опрос htmx (`overview.html`, `overview_fragment.html`), GET-фильтр
`registry.html` (`hx-select` + `hx-replace-url`) — идиома для переиспользования. Per-row кнопки `hx-delete` при «выбрать все»
кладут все отмеченные адреса в URL (уже сейчас дефект, при тысячах — фатальный).
- Тик оркестратора не читает всю очередь (`ClaimNextQueued`, `ListChecking` — по индексу), пропускная способность не зависит от размера очереди.
## Дизайн
### 1. OpenStack: постраничное чтение, таймауты, ретраи (`internal/openstack`, `internal/config`)
- Новый метод интерфейса `ListFreeFloatingIPs(ctx, pageSize int, onPage func(page []FloatingIP) error) (pages int, err error)`:
цикл «страница → `onPage`»; для каждой страницы один запрос `floatingips.List(ListOpts{Limit, Marker=lastID})` с `EachPage`
(возврат `false` после первой страницы) — собственная пагинация по `marker`, а не `next`-ссылка (за прокси она может указывать на
внутренний хост). Свой `ListOptsBuilder`, добавляющий `fields=id&fields=floating_ip_address&fields=port_id&fields=project_id`
(в gophercloud `ListOpts.Fields` нет; на стенде проверено: `fields` + `marker` работают). Фильтр свободных — на клиенте
(`PortID==""`); серверный `status=DOWN` не используем (надмножество, возможны гонки статуса).
- **Ретраи страницы** с backoff (по умолчанию 5 попыток, 1→2→4→8→16 с) на сетевые ошибки, `EOF/RemoteDisconnected`, 5xx и 429
(на стенде уже наблюдался `RemoteDisconnected` на второй странице); 4xx (кроме 429) — без ретрая. Контекст отменяет ретраи.
- **Таймаут на запрос**: `provider.HTTPClient.Timeout` (`openstack.request_timeout_seconds`, 60) — закрывает и вечные зависания в `Tick`
(`GetFloatingIPByAddress`/`Associate`/`Disassociate`), ключевой побочный эффект.
- Конфиг: `openstack.list_page_size` (200), `openstack.request_timeout_seconds` (60), `orchestrator.fip_scan_timeout_seconds` (1800),
`openstack.list_page_retries` (5); дефолты в `LoadControlAPI`, примеры в `configs/*.example.yaml` и `rxprod-compose/sources/`.
- `ListFloatingIPs` (полный список) остаётся для совместимости и тестов (реализован поверх нового метода).
- `MockClient`: пагинация (`PageSize`), счётчик вызовов/страниц, очередь ошибок `ListFailures []error` (по одной на запрос),
опциональная задержка страницы, `SeedMany(n)` для тестов на тысячи.
### 2. Фоновое задание скана (`internal/orchestrator/scanjob.go`)
- `ScanJob` в `Orchestrator`, **нулевое значение пригодно** (тесты строят `&Orchestrator{…}` литералом): `sync.Mutex`, текущий прогресс,
`cancel`. Метод `StartScan(opts) (ScanStatus, started bool)` — single-flight: если скан уже идёт, возвращает его статус
(`started=false`). Горутина работает на контексте жизни процесса (хранится в `Orchestrator`, задаётся из `main`, по умолчанию
`context.Background()`), **не** на `r.Context()`; общий дедлайн `fip_scan_timeout_seconds`.
- Опции: `ClearFirst bool` (для автоцикла), `DryRun bool` (только обнаружить и посчитать, очередь не трогать — безопасная проверка
на живом стенде и полезная функция для оператора).
- Фазы и прогресс: `idle → clearing → listing → enqueuing → done|error|cancelled`; поля `pages`, `discovered`, `free`, `added`,
`requeued`, `reordered`, `skipped_in_progress`, `started_at`, `finished_at`, `error`.
- **Алгоритм:** (1) `ClearFirst` → `ClearQueue`; (2) чтение всех страниц в память (6440 строк — килобайты), `free = PortID==""`;
(3) сортировка по IPv4 по возрастанию (детерминированный порядок очереди); (4) **только после полного обнаружения** — `SubmitIPs`
кусками по 500 в этом порядке (`base=MAX+1` пересчитывается на вызов ⇒ порядок сохраняется; транзакции короткие, единственное
соединение освобождается между кусками); проверки стартуют, как только появляются первые `queued`; (5) одно событие `fip_scan`
с итоговыми счётчиками. Ошибка чтения после ретраев ⇒ **ничего не ставится в очередь** (для ручного скана очередь не меняется),
статус `error` с причиной; повтор — кнопкой или следующим циклом. Ошибка БД посередине ⇒ уже поставленные куски остаются
(повтор идемпотентен: `SubmitIPs` переупорядочивает/пропускает).
- `ScanFloatingIPs(ctx)` остаётся тонкой синхронной обёрткой («запустить и дождаться») для существующих тестов/скриптов.
- Периодический `scanTickerC` вызывает неблокирующий `StartScan`.
### 3. Масштабирование БД и запросов (`internal/db`, миграция `0009`)
- Миграция `0009_scale_indexes.sql`: `idx_ip_queue_registry ON ip_queue(registry_id)` (убирает O(n²) в реестре),
`idx_ip_queue_state_aggregated ON ip_queue(state, aggregated_at)` (список «последние завершённые»).
- Новые запросы: `CountIPsByState`, `CountIPsByResult` (GROUP BY — вместо загрузки всех строк в `/admin/status`),
`AnyNonTerminalIP` (`SELECT EXISTS … state NOT IN (done,failed,occupied)`), `ListIPsPage(filter{states[], q, result, order},
limit, offset) → (items, total)`, `ListRegistryPage(filter{q, lastResult}, limit, offset) → (items, total)` — **LIMIT/OFFSET до**
`fillRegistrySummary`, поэтому 3–4 запроса на строку платят только строки страницы. Фильтр `lastResult` реализуется одним SQL:
`ip_registry r LEFT JOIN ip_queue q ON q.registry_id=r.id`, условие `(q.id IS NOT NULL AND q.overall_result=?) OR (q.id IS NULL AND
<подзапрос по checks последнего цикла: pass/fail/partial>=?)` — та же семантика, что `fillRegistrySummary`/`lastCycleResultFromChecks`,
без денормализации и миграции данных.
- `ClearQueue`: set-based очистка без цикла по адресам — `UPDATE validators SET current_ip_id=NULL…`, `UPDATE checks SET ip_id=NULL`,
`UPDATE events SET ip_id=NULL`, `DELETE ip_site_checks`, `DELETE ip_queue` (5 запросов, O(n)); disassociate FIP только для строк с `FIPID`;
событие `queue_cleared` — счётчик и усечённый список (не 6440 адресов). `Orchestrator.DeleteIPs` — выбор строк без N `GetIPByAddress`.
### 4. HTTP API (`internal/httpapi`) — обратная совместимость сохраняется
| Метод | Путь | Изменение |
|---|---|---|
| POST | `/admin/ips/scan` | `202 {state, started_at, …}` (запуск или уже идущий скан — `202` с текущим статусом); `?dry_run=true`; `?wait=true` — старая синхронная семантика (`200` + счётчики) для curl/скриптов |
| GET | `/admin/ips/scan` | **новый**: статус и прогресс скана (admin-токен) |
| GET | `/admin/ips` | без параметров — как раньше (массив); с `limit` — конверт `{items,total,limit,offset}`; фильтры `state` (csv), `q`, `result`, `order` |
| GET | `/admin/registry` | то же: `limit/offset/q/last_result` → конверт с `total` |
| GET | `/admin/status` | + `results_by_overall`, счёт через `GROUP BY` |
| GET | `/admin/overview` | **опционально** одним запросом: счётчики, активные (≤100), последние завершённые (N), ближайшие в очереди (≤10), статус скана и автоцикла |
Новые admin-маршруты попадают в таблицу `routes.go` с `accessAdmin`; `TestRouteTableClassification` (`auth_test.go`) обновить (+1–2 admin).
### 5. Автоцикл (`internal/orchestrator/autocycle.go`, `queries_autocycle.go`, `dashboard/dto.go`)
- Новая фаза **`scanning`** (миграция не нужна — валидатор фаз в `UpdateAutoCycleState` расширить; подписи `PhaseLabel` — «сканирование Floating IP»).
- `autoCycleStartRun` перестаёт блокировать цикл: запускает `StartScan{ClearFirst:true}` (очистка + скан целиком в фоне, **литерал
сценария пользователя сохранён: очистка → скан**) и сразу переводит фазу в `scanning`; `autoCycleMu` держится только на время
чтения/записи состояния, а не на всё время скана ⇒ `Tick` и `Start/Stop` не блокируются.
- Шаг `scanning`: опрос статуса задания. `running` → выход; `error` → существующий путь `fail()` (исход `error`, повтор через
`interval_seconds`); `done` и `free==0` → `no_free_ips`; `done` → фаза `running`, `last_scanned_free`, **`run_started_at` = конец скана**
(лимит `max_run_seconds` считается от конца скана). Таймаут самого скана — `fip_scan_timeout_seconds`.
- **Восстановление после рестарта:** фаза `scanning` без живого задания ⇒ заново `StartScan{ClearFirst:true}` (идемпотентно).
- `Stop` отменяет задание скана (исход `stopped`, если шёл скан или проверка).
- `autoCycleCheckRun`: проверка завершения — `AnyNonTerminalIP` вместо `ListIPs` каждый тик; `COUNT` только при завершении.
- Документировать: при тысячах адресов `max_run_seconds=0`; цикл длится часы; интервал отсчитывается от завершения.
### 6. Дашборд (`internal/dashboard`, шаблоны, CSS)
- **Скан-кнопка и прогресс:** `hx-post="/ips/scan"` возвращает панель `scan_progress` (вне `#ips-form`): стадия, `<progress>`,
прочитано/свободных/добавлено, время, ошибка; пока `running` панель сама опрашивает `GET /ips/scan/status` (`hx-trigger="every 2s"`),
по завершении — без триггера и с `HX-Trigger: scan-finished`, по которому таблица перезагружается (`hx-get` + `hx-select`);
кнопка блокируется на время скана; `409`/ошибки — штатным баннером (`bannerFor`). Скан-старт возвращается мгновенно, таймаут
10 с больше не проблема.
- **Пагинация `/ips` и `/registry`:** `page`, `per_page` (50 по умолчанию; 25/50/100/200), partial `pager` («Показано a–b из N», ‹ ›,
`url.Values` для экранирования); фильтры на сервере: `/registry` — `q`, `status`; `/ips` — новая форма `q` + состояние
(все / в очереди / в работе / done / failed / occupied / результат), идиома GET + `hx-select` + `hx-replace-url`.
Скрытые `page/q/state` внутри `#ips-form`, чтобы мутации возвращали ту же страницу.
- **Массовые операции:** чекбоксы — только строки страницы + счётчик «Выбрано на странице k из 50»; при отмеченном заголовке и
`total > per_page` — ссылка «Выбрать все N по фильтру» (`scope=all`): адреса разрешаются на сервере постранично и уходят в
`DeleteIPs`/`SubmitIPs` кусками по ~500. Per-row и scan/clear-кнопки получают `hx-params="page,q,state"` (чинит URL из тысяч адресов).
`hx-confirm` с реальным числом (`Удалить ВСЕ {{.Total}} адресов…`).
- **«Обзор»:** без `ListIPs`; `Status` (+`results_by_overall`) и ограниченные списки: «В работе» (активные состояния), «В очереди: Q»
(счётчик + ссылка на `/ips?state=queued`, ≤10 ближайших), «Последние N завершённых»; `occupied` — терминальное состояние.
Индикатор в блоке статистики (OOB-обновление, как сейчас): «Готово D из T (P%) · в работе A · в очереди Q» + `<progress>`,
оценка времени по скорости последних завершённых; статус скана («Сканирование: прочитано X») и автоцикла.
- `client.go`: `ListIPsPage`, `ListRegistryPage`, `ScanStatus`; длинный таймаут/отдельный клиент для clear и массовых операций.
### 7. Прочее
- `routes`, `dto_admin.go`, `docs/API.md` (202/статус/пагинация/`dry_run`/`wait`), `docs/USAGE.md` (скан, автоцикл: фаза `scanning`,
ожидание ≈ N×50 с/валидаторов, рычаги), `docs/DASHBOARD.md`, `docs/LOCAL_E2E.md`, `README.md`, `configs/*.example.yaml`
(+ копии в `rxprod-compose/sources/` и docker-примеры).
- `rxprod-compose/control-api.yaml` (живой конфиг) — при необходимости задать `max_run_seconds` автоцикла = 0 (уже 0) и
`openstack.list_page_size`; по умолчанию достаточно дефолтов.
- Опционально (не входит): переупорядочить `Tick` (сначала sweeps, потом `assignIdleValidators`) — экономит до 5 с на адрес (~10 %).
## Тесты (минимальные, в стиле существующих)
- `internal/openstack`: пагинация по marker на моке (N=2500, `PageSize=200`), ретрай страницы при `ListFailures`, отмена контекста;
классификация ретраемых ошибок.
- `internal/orchestrator`: задание скана — single-flight, прогресс, `DryRun`, ошибка чтения ⇒ очередь не изменена, куски по 500,
порядок по возрастанию IP, 6440 адресов за разумное время; автоцикл: фаза `scanning`, шаги с явным `now`, ошибка скана,
`no_free_ips`, рестарт в фазе `scanning`, `Stop` отменяет скан, `Tick` не блокируется во время долгого скана (мок с задержкой).
- `internal/db`: `ListIPsPage`/`ListRegistryPage` (фильтры, total, LIMIT до summary), `CountIPsBy*`, `AnyNonTerminalIP`,
set-based `ClearQueue`, тест на 6440 строк (время и отсутствие N+1).
- `internal/httpapi`: 202/409-семантика скана, `?wait=true`, `?dry_run=true`, `GET /ips/scan`, конверт пагинации, обратная
совместимость без `limit`; обновить `TestScanFloatingIPsEndpoint` и счётчики в `auth_test.go`.
- `internal/dashboard`: панель прогресса и остановка опроса, пагинация/pager, фильтры `/ips`, `scope=all`, Overview на ограниченных
списках при тысячах `queued`, `hx-params`; обновить `fakeControlAPI` и затронутые тесты (`TestIPsScan` и др.).
- `scripts/run-local-e2e.sh`: автоцикл проходит через фазу `scanning` (мок-пагинация), полный сценарий остаётся зелёным за 90 с.
## Верификация
1. `go build ./... && go vet ./... && go test ./...` и `go test -race` для `orchestrator`, `openstack`, `httpapi`, `dashboard`, `db`.
2. `scripts/run-local-e2e.sh` (с токенами) — зелёный, автоцикл проходит `scanning → running → waiting`.
3. **Живой стенд, безопасно (чтение):** `POST /admin/ips/scan?dry_run=true` — скан проходит все страницы реального Neutron, прогресс
идёт, итог ≈ 6440 свободных, очередь не меняется, дашборд не получает таймаута. Замер времени скана и нагрузки.
4. **Живой стенд, по согласованию:** кнопка «Сканировать Floating IP» — адреса поставлены в очередь, проверки идут, `/ips`,
`/registry`, «Обзор» остаются быстрыми (проверка размера ответов и времени), «Очистить всё» отрабатывает за секунды.
Решение о реальной постановке 6440 адресов (≈18 ч проверок на 5 валидаторах) принимает пользователь; перед этим — бэкап БД.
5. Автоцикл на живом стенде — только после п. 4 и с согласия пользователя; наблюдать фазу `scanning`, отсутствие блокировки `Tick`.
6. Реальный браузер (Playwright из venv): прогресс скана, пагинация, фильтры, выбор «все N по фильтру», отсутствие JS-ошибок.
@@ -0,0 +1,81 @@
# Ревью и тестирование: скан Floating IP и автоцикл при тысячах адресов
> Дата: 2026-10-01 18:59 MSK · План: [2026-10-01_18-19_fip-scan-at-scale-plan.md](2026-10-01_18-19_fip-scan-at-scale-plan.md)
> Статус: **реализовано и проверено на живом окружении**; реальная постановка 6440 адресов в очередь и включение автоцикла на живом стенде **не выполнялись** — ждут решения пользователя.
## Итог
Исходная ошибка («control-api недоступен: context deadline exceeded» при нажатии «Сканировать Floating IP») устранена: на живом стенде кнопка
возвращает панель прогресса за 0,7 с, а сканирование реального Neutron (6441 Floating IP, 6440 свободных) проходит за ≈ 1,5–2 минуты в фоне
с видимым прогрессом. Код написан двумя агентами параллельно (Sonnet 5.5): серверная часть и дашборд, ревью и все проверки — независимо (Sonnet 5.5, high).
Найден и исправлен один дефект автоцикла и одна мелочь в клиенте OpenStack; остальные замечания — ограничения дизайна, перечислены ниже.
## Причина исходной ошибки
`ListFloatingIPs` запрашивал весь список одним запросом без `limit` и без таймаута: при ≈ 6,4 тыс. адресов Neutron отвечал дольше минуты, дашборд ждал 10 с,
а запрос был привязан к `r.Context()` — при обрыве соединения скан отменялся и не мог завершиться. Ошибка нигде не логировалась.
Побочные открытия: у клиента OpenStack вообще не было таймаутов и ретраев; `ListRegistry` был O(n²) (нет индекса `ip_queue(registry_id)`);
`/ips` отдавал ≈ 8 МБ HTML, а «Обзор» каждые 5 с тянул ≈ 3 МБ JSON.
## Что реализовано
| Область | Изменения |
|---|---|
| OpenStack | `ListFreeFloatingIPs` — постраничное чтение по `marker` (200 на страницу, `fields=` сокращает ответ), повтор страницы с backoff на обрывы/`RemoteDisconnected`/5xx/429, таймаут запроса (`openstack.request_timeout_seconds`, 60 с — закрывает и зависания в тике оркестратора); `MockClient` с пагинацией, `ListFailures`, `PageDelay`, `SeedMany` |
| Скан-задание | `orchestrator/scanjob.go`: single-flight фоновое задание на контексте процесса (не `r.Context()`), фазы `clearing → listing → enqueuing → done/error/cancelled`, прогресс; сначала полное обнаружение, затем `SubmitIPs` кусками по 500 по возрастанию IP; сбой чтения ⇒ очередь не меняется; `dry_run`; синхронная обёртка `ScanFloatingIPs` сохранена |
| Автоцикл | фаза `scanning`: очистка и скан — одно фоновое задание, цикл оркестратора и `autoCycleMu` не блокируются; восстановление после рестарта; `Stop` отменяет скан; завершение определяется `EXISTS`, а не чтением всей очереди каждый тик |
| БД | миграция `0009` (индексы `ip_queue(registry_id)`, `ip_queue(state, aggregated_at)`); `ListIPsPage`, `ListRegistryPage` (LIMIT/OFFSET до расчёта сводки, фильтр итога одним SQL), `CountIPsByState/Result`, `AnyNonTerminalIP`; `ClearAllIPs` — 5 запросов вместо цикла по адресам |
| API | `POST /admin/ips/scan` → `202` (`dry_run`, `wait`), новый `GET /admin/ips/scan`, пагинация и фильтры у `GET /admin/ips` и `/admin/registry` (без `limit` — прежний массив), `results_by_overall` в `/admin/status`, `count` у `clear` |
| Дашборд | панель прогресса скана и «Пробное сканирование»; постраничные `/ips` и `/registry` с серверными фильтрами; «Обзор» на счётчиках и ограниченных списках (прогресс «Готово D из T», оценка времени); «Выбрать все N по фильтру»; `hx-params` на кнопках (исправлен дефект — отмеченные адреса попадали в URL `hx-delete`); подтверждения с реальным числом; длинный таймаут для массовых операций |
| Конфиг и документы | `openstack.list_page_size/request_timeout_seconds/list_page_retries`, `orchestrator.fip_scan_timeout_seconds`; `API.md`, `USAGE.md`, `DASHBOARD.md`, `README.md`, примеры конфигов |
## Результаты проверок
| Проверка | Результат |
|---|---|
| `gofmt`, `go build ./...`, `go vet ./...` | чисто |
| `go test ./...` (включая тесты на 6440 адресов) | все пакеты зелёные |
| `go test -race -short` (openstack, orchestrator, db, httpapi, dashboard, config) | зелёные (тесты на 6440 адресов под `-race` слишком долгие, пропускаются по `-short`; агент прогонял их полностью) |
| `scripts/run-local-e2e.sh` (с токенами) | exit 0: автоцикл прошёл через `scanning`, проверки реальными агентом и пробером — `pass` |
| **Живой стенд — пробный скан реального Neutron** (`POST …/scan?dry_run=true`) | 33 страницы, **6441 найдено / 6440 свободных за 93 с**, `202` за 2 мс, повторный `POST` присоединился к идущему заданию, **API отвечал за 2–4 мс всё время скана**, очередь осталась пустой |
| **Живой дашборд (настоящий Chromium)**, кнопка «Пробное сканирование» | панель за 0,7 с без баннера ошибки, прогресс по страницам, итог «готово: 33 страницы, 6441, 6440, время 1 мин 48 с» |
| Изолированный mock-стенд на **6440 адресах**, настоящий Chromium (17 из 19 автопроверок, 2 — ложные, см. ниже) | `/ips` **68 КБ за 0,18 с** (было ≈ 8 МБ), фрагмент «Обзора» **3 КБ за 0,05 с**, `/registry` 34 КБ за 0,12 с; пагинация и серверный поиск; прогресс скана (читаются страницы → ставятся в очередь → готово); «Очистить всё» с реальным числом в подтверждении — **0,5 с**; «Выбрать все 6440 по фильтру» + массовое удаление — **8 с**; JS-ошибок нет; ошибок в логе control-api нет |
| Миграция `0009` на живой БД | `user_version = 9`, оба индекса созданы, данные не тронуты |
Примечание: два «FAIL» в браузерном скрипте mock-стенда — ошибка самого скрипта: панель показывает состояние заглавными («ГОТОВО», CSS), а скрипт искал строчные.
Выведенный текст панели подтверждает успех (`добавлено6440`, `найдено адресов6440`). Реальных провалов нет.
## Замечания ревью
| № | Серьёзность | Замечание | Статус |
|---|---|---|---|
| 1 | средняя | **Автоцикл «усыновлял» чужое сканирование.** Если в момент старта цикла уже шло ручное/периодическое/**пробное** сканирование, `StartScan` возвращал `started=false`, а цикл переходил в `scanning` и ждал чужое задание. Пробное ничего не ставит в очередь, ручное не очищает очередь ⇒ цикл переходил в `running` над пустой/нетронутой очередью и сразу отчитывался `completed` (`runs_total+1`) без единой проверки | **исправлено**: если собственный скан не стартовал, цикл ничего не меняет и пробует снова на следующем такте (после окончания чужого); добавлен тест `TestAutoCycleWaitsForForeignScanInsteadOfFollowingIt` (падал до правки) |
| 2 | низкая | Клиент OpenStack заполнял `ProjectID` только из `tenant_id`; при `fields=` Neutron может вернуть лишь `project_id` | **исправлено** (запасной вариант `project_id`); поле нигде не влияет на логику |
| 3 | низкая | `aggregated_at_desc` сортирует по `strftime(...)` — временные метки хранятся как RFC3339Nano, и сырая сортировка текстом неверна (поймал тест агента); индекс `(state, aggregated_at)` помогает фильтру по состоянию, но не сортировке | принято; на 6440 строк незаметно |
| 4 | низкая | `StopAutoCycle` в фазе `scanning` вызывает `CancelScan`, который ждёт до 5 с под `autoCycleMu`: «Выключить» может занять до 5 с, а следующий шаг цикла — подождать | принято |
| 5 | низкая | Если скан завершился ошибкой после «Очистить» (шаг 1 цикла), очередь остаётся пустой до следующего цикла (`interval_seconds`); исход — `error` с причиной | принято, описано в `USAGE.md`; при желании — отдельная доработка (повтор скана сразу) |
| 6 | низкая | Статус скана хранится в памяти: после рестарта control-api он `idle`; автоцикл в фазе `scanning` при этом корректно перезапускает скан | принято |
| 7 | инфо | Отмена сканирования (`CancelScan`) вызывается только из `Stop` автоцикла; ручной кнопки/эндпоинта отмены нет | не входило в план |
| 8 | инфо | Дашборд: мутации заменяют `#ips-table-wrap` целиком (`outerHTML`), чтобы `hx-get` обёртки всегда указывал на текущую страницу/фильтр; убраны функции `filterQueueItems/filterRegistryItems/currentlyChecking/lastCompleted` вместе с тестами (фильтрация перенесена на сервер); сводка «последние N» считается по показанному (возможно, отфильтрованному) окну, а общие итоги — отдельной строкой | принято |
| 9 | инфо | Тесты на 6440 адресов под `-race` занимают 40–90 с на пакет — пропускаются по `-short` | принято |
## Пропускная способность (важно для автоцикла)
Проверка не стала быстрее — стало возможным её запустить. По фактическим данным стенда слот на адрес ≈ 50 с на валидатор (из них 30 с — `fip_settle_seconds`):
**6440 адресов ≈ 18 ч на 5 валидаторах, ≈ 9 ч на 10, ≈ 4,5 ч на 20.** Для автоцикла `max_run_seconds` должен оставаться `0`. Рычаги — число валидаторов и (осторожно)
`fip_settle_seconds`. На странице «Обзор» виден прогресс и оценка времени.
## Состояние живого стенда (`rxprod-compose`)
- Развёрнуты новые образы `civ-capi`, `civ-adash`, `civ-prober` (и пересобран `civ-agent`); миграция `0009` применена. Предыдущие образы сохранены под тегом `:pre-scale`
(откат: `docker tag civ-capi:pre-scale civ-capi:latest` и `docker compose up -d`; на `0009` откат БД не нужен — это только индексы). Бэкап БД перед обновлением — в каталоге scratchpad сессии (`/tmp`, временный).
- Очередь пуста (5 адресов прежней работы остались в реестре). **Реальная постановка 6440 адресов и автоцикл на живом стенде не запускались**: это ≈ 18 ч реальных проверок,
решение за пользователем. Перед запуском рекомендую «Пробное сканирование» (уже отработало штатно) и бэкап БД.
- Токен агентов по-прежнему не включён (внешние валидаторы и пробер `rxyc` со старыми бинарниками) — см. [ревью аутентификации](2026-10-01_11-31_authentication-review.md).
Новые бинарники для внешних валидаторов — в `bin/` (после раскатки токена агентов их можно обновить одновременно).
- `bin/` пересобран (`CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"`), `SHA256SUMS` обновлён. Временные контейнеры `civ-scale-*` удалены.
## Что осталось
- Решение пользователя: поставить 6440 адресов в очередь («Сканировать Floating IP») и/или включить автоцикл на живом стенде.
- По желанию: кнопка/эндпоинт отмены скана (замечание 7), повтор скана внутри цикла при ошибке (замечание 5), `Cache-Control: no-store` (из ревью аутентификации).
@@ -0,0 +1,110 @@
# План: самопроверка через control-api (дополнительный способ сверки публичного IP)
> Дата: 2026-10-02 03:06 · Статус: **реализовано и проверено** (юнит-тесты, локальный e2e с `ip_echo` и с `control_api`) (решения пользователя — в конце)
## Зачем
Самопроверка агента подтверждает, что исходящий трафик валидатора идёт через выданный Floating IP: агент спрашивает свой
публичный адрес у внешнего IP-echo сервиса и сверяет с назначенным адресом. Сейчас это единственный способ, и он хрупкий:
1 октября таймауты `https://ifconfig.me/ip` дали волну провалов self-check (33 случая за вечер).
`control-api` в текущем развёртывании стоит **во внешнем окружении** (вне облака), валидаторы подключаются к нему **напрямую**.
Значит, соединение валидатора с ним выходит наружу через Floating IP, и `control-api` сам видит публичный адрес источника.
Это даёт второй способ сверки без сторонних сервисов: агент спрашивает у `control-api`, с какого адреса тот его видит.
Требование: **существующий способ (IP-echo) сохраняется**, новый добавляется как опция агента.
## Решение в двух строках
1. `control-api` получает ручку «с какого адреса ты меня видишь».
2. Агент получает настройку `self_check.methods` — список способов в порядке приоритета; по умолчанию `[ip_echo]` (всё как сейчас).
## Конфигурация агента
```yaml
self_check:
timeout_seconds: 10
methods: [control_api, ip_echo] # по умолчанию [ip_echo]
ip_echo_urls: [...] # без изменений
```
- Допустимые значения: `ip_echo` (текущий способ), `control_api` (новый). Неизвестное значение — ошибка при старте агента.
- **Самопроверка успешна, если её подтвердил любой из способов.** Способы пробуются по порядку приоритета (первый —
главный); остановка на первом успешном. К следующему способу переходим и при отсутствии ответа (ошибка, таймаут,
404/5xx), и при несовпадении адреса. Провал — только если не подтвердил ни один способ; в `detail` попадает причина по
каждому способу.
- Внутри способа `ip_echo` поведение прежнее: URL перебираются по порядку, переход к следующему URL только при ошибке.
- Пустой список или отсутствие ключа → `[ip_echo]`. Агент без новой настройки ведёт себя ровно как раньше.
- Для внешнего размещения `control-api` в примерах и рекомендациях стоит `[control_api, ip_echo]`: способ через API в приоритете.
- Итог в `detail`: `detected_egress_ip=<ip> matched (control_api)`; видно в событиях адреса и в дашборде.
## Control API
**Новая ручка:** `GET /api/v1/agents/{id}/observed-ip` → `200 {"ip":"90.156.213.5","source":"remote_addr"}`.
- Уровень доступа — **открыто**, как `heartbeat` и `assignment`: ручка отдаёт только адрес самого вызывающего, секретов нет.
- `{id}` должен быть известным валидатором, иначе `404` (чтобы ручка не превращалась в публичный «узнай свой IP»).
- Адрес берётся только из `r.RemoteAddr`, приводится к каноничному виду (`::ffff:1.2.3.4` → `1.2.3.4`).
- Заголовки `X-Forwarded-For`/`X-Real-IP` **не учитываются**: подключение прямое, а доверие к заголовку позволило бы
валидатору подделать адрес и пройти проверку. Если появится обратный прокси, понадобится отдельная настройка
доверенных прокси — сейчас она не нужна (решение 1).
## Агент (`internal/agentcore`)
- Новая функция `detectViaControlAPI`: `GET /api/v1/agents/{id}/observed-ip` на `control_api_url`.
- **Новое TCP-соединение на каждый вызов** (отдельный `http.Transport` с `DisableKeepAlives`). Это ключевой момент:
соединение, открытое до привязки Floating IP (heartbeat, assignment), остаётся в старом NAT-состоянии и покажет
прежний адрес; общий клиент `apiclient` использовать нельзя.
- Токен агентов в этот запрос не нужен (ручка открытая) и не отправляется.
- Таймаут `self_check.timeout_seconds` (сейчас 10 с) действует **на каждый способ отдельно**: при общем дедлайне зависший
первый способ (приоритетный `control_api`) съел бы всё время, и запасной не успел бы ответить. Общий предел — таймаут × число способов.
- `detectPublicIP` заменяется перебором `methods` по приоритету; `fetchIPEcho` не меняется.
- Диагностика: если `control-api` вернул **частный** адрес (RFC 1918 и т. п.), в `detail` пишется подсказка: «control-api
доступен по внутренней сети, самопроверка через него невозможна; используйте ip_echo».
## Ограничение способа (важно для документации)
Способ `control_api` корректен только если соединение валидатора с `control-api` **выходит через внешнюю сеть**
(SNAT Floating IP). Если `control-api` достижим из облака по внутренней сети, он увидит частный адрес валидатора, и
этот способ всегда будет давать несовпадение (при `[control_api, ip_echo]` проверка пройдёт по `ip_echo`).
Если порт `control-api` опубликован через Docker, при выкладке проверить, что ручка показывает внешний адрес клиента, а
не адрес шлюза Docker.
## Откат и совместимость
- Новый агент + старый `control-api`: ручки нет (`404`), при `methods: [control_api, ip_echo]` агент переходит на `ip_echo`.
- Старый агент + новый `control-api`: ничего не меняется, ручка просто не вызывается.
- Откат: убрать `methods` из конфига агента (или вернуть `[ip_echo]`) и перезапустить агент.
- Схема БД и протокол `self-check` (`POST /agents/{id}/self-check`) не меняются.
## Затрагиваемые файлы
| Файл | Изменение |
|---|---|
| `internal/config/config.go` | `SelfCheckCfg.Methods`, дефолт `[ip_echo]` и проверка значений |
| `internal/httpapi/routes.go`, `handlers_agent.go` | маршрут и обработчик `observed-ip` |
| `internal/agentcore/agentcore.go` | `detectViaControlAPI`, перебор `methods` по приоритету |
| `configs/validator-agent.example.yaml` | пример и комментарии |
| `docs/API.md`, `docs/SETUP.md`, `docs/USAGE.md`, `README.md` | описание ручки, опции, ограничения |
| `bin/validator-agent`, `bin/control-api`, `SHA256SUMS` | пересборка |
## Тесты
- **config:** дефолт `[ip_echo]`; допустимые значения; ошибка на неизвестном способе.
- **httpapi:** прямой адрес; IPv4-mapped IPv6; заголовок `X-Forwarded-For` игнорируется; неизвестный валидатор → `404`.
- **agentcore:** порядок способов; успех второго способа после ошибки или несовпадения первого; провал, когда не
подтвердил ни один; каждый вызов открывает новое соединение (тестовый сервер считает соединения); подсказка про частный адрес.
- **e2e:** `scripts/run-local-e2e.sh` остаётся на `ip_echo` (проверка обратной совместимости).
## Выкладка
1. `control-api` с новой ручкой (поведение не меняется): пересборка образа, перезапуск.
2. Агенты на ВМ-валидаторах: новый `bin/validator-agent` и `methods: [control_api, ip_echo]` в их конфиге. Один валидатор
для начала, проверить `detail` в событиях (`matched (control_api)`), затем остальные.
## Решения пользователя (2026-10-02)
1. Подключение валидаторов к `control-api` — **напрямую** (без обратного прокси): `trusted_proxies` не нужен.
2. Ручка `observed-ip` — **открытая**.
3. Достаточно **одной успешной самопроверки любым из способов**; при внешнем размещении API способ через ручку API —
**в приоритете** (первый в списке).
@@ -0,0 +1,146 @@
# План: исправление оркестратора (двойная выдача валидатору) и очистки очереди
> Дата: 2026-10-02 09:02 UTC · Статус: **реализовано и проверено** (юнит-тесты с `-race`, локальный e2e; выкладка и приёмка на стенде — ниже). Решения пользователя: предел очистки 10 минут и 8 параллельных отвязок приняты как базовые
> Основание: [analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md](../../analysis/2026-10-02_08-56_1026-addresses_mass-check-analysis.md)
## Context
Массовая проверка 2 октября остановилась на 1026 из 6440 адресов. 7 из 20 валидаторов «залипли»: v1, v12, v13 не взяли
ни одного задания после 07:17 и 07:33; v7, v16, v17, v3 залипали временно. Итог: 198 сбросов лизинга, 41 ошибка привязки
Floating IP (`409 fixed IP already has a floating IP`), **42 адреса в `fail` без единой выполненной проверки**, потеря
~27% пропускной способности. Отдельно «Очистить всё» не уложилась в таймаут клиента (256 с вместо секунд) и сначала
оборвалась с 600 ошибками `context canceled`.
Цель: валидатор в любой момент держит не больше одного адреса; потеря heartbeat не приводит к двойной выдаче;
«Очистить всё» выполняется за секунды и не прерывается разрывом соединения.
## Причины (по коду, подтверждены логами и БД)
| № | Причина | Где |
|---|---|---|
| 1 | Heartbeat возвращает `unreachable` → `idle`, не глядя на `current_ip_id`: занятый валидатор снова считается свободным | `internal/db/queries_validators.go:41` (`Heartbeat`), `:31` (`RegisterValidator`, возврат из `unreachable`) |
| 2 | Освобождение валидатора идёт **по имени**, а не по адресу, который он держит: завершение старого адреса освобождает валидатор, уже взявший новый. Так же `RequeueOrFail` (в т. ч. при сбросе лизинга) и `MarkFIPOccupied`, `FreeValidator`. Освобождённый ставится в `idle` даже если он `unreachable` — мёртвый валидатор получает новые адреса каждые 3 минуты | `internal/db/queries_ipqueue.go` (`ReleaseFIP`, `RequeueOrFail`, `MarkFIPOccupied`), `queries_validators.go:143` (`FreeValidator`), `internal/orchestrator/orchestrator.go:469` |
| 3 | Моя регрессия: защита от дублей привязки ключуется по валидатору (`assign:<validator>`). Вторая выдача того же валидатора не запускает привязку и стоит в `assigning_fip` до конца лизинга | `internal/orchestrator/orchestrator.go:149` |
| 4 | Агент шлёт heartbeat только между заданиями. Адрес с 3–4 таймаутами внешних проверок (~40 с) блокирует его дольше порога 30 с | `internal/agentcore/agentcore.go` (`Run`, `pollOnce`) |
| 5 | «Очистить всё» отвязывает FIP у **всех** строк с непустым `fip_id`, а он остаётся у `done`/`failed`. Больше 1000 последовательных вызовов OpenStack | `internal/db/queries_ipqueue.go` (`ListFIPRefs`, `ListFIPRefsByAddresses`), `internal/orchestrator/orchestrator.go` (`ClearQueue`, `DeleteIPs`) |
| 6 | Очистка работает на контексте HTTP-запроса: разрыв соединения клиентом обрывает её посреди дела (отвязано часть, БД не очищена) | `internal/httpapi/handlers_admin.go:322` |
## Инварианты, которые вводим
- **I1.** Валидатор держит не более одного адреса: `validators.current_ip_id = X` тогда и только тогда, когда у строки `X`
`owner_validator_id` равен этому валидатору и состояние не терминальное (`done`, `failed`, `occupied`).
- **I2.** `idle` означает `current_ip_id IS NULL`. Состояния `unreachable` и `unregistered` не затираются освобождением.
- **I3.** Валидатор освобождает только тот адрес, который он сейчас держит. Освобождение и возврат по лизингу чужого или
устаревшего адреса состояние валидатора не меняют.
## Изменения
### 1. База данных (без миграций, только запросы)
`internal/db/queries_validators.go`, `queries_ipqueue.go`:
- **`Heartbeat`:** `unreachable` → `assigned`, если `current_ip_id IS NOT NULL`, иначе `idle`.
То же в `RegisterValidator` (возврат из `unreachable`/`unregistered`).
- **Общая функция освобождения** `freeValidatorTx(tx, validatorID, ipID)`:
`UPDATE validators SET current_ip_id=NULL, state = CASE WHEN state='unreachable' THEN state ELSE 'idle' END WHERE validator_id=? AND current_ip_id=?`.
Используют `ReleaseFIP`, `RequeueOrFail`, `MarkFIPOccupied`, `FreeValidator` (получает второй аргумент — id адреса).
`deleteIPTx` (уже по `current_ip_id`) и `ClearAllIPs` переводятся на тот же `CASE`, чтобы не затирать `unreachable`.
- **`ClaimNextQueued`:** условие обновления валидатора дополняется `AND current_ip_id IS NULL`.
- **`ReconcileValidators`** (новая, вызывается из `Orchestrator.Tick`): лечит нарушение инвариантов, если они всё же возникли
(падение процесса, старые строки): валидатор с `current_ip_id`, чья строка не существует, терминальна или принадлежит другому
валидатору, освобождается; строка в `assigning_fip`/`awaiting_self_check`/`checking`, чей владелец не ссылается на неё,
не трогается (её вернёт сброс лизинга). Один короткий запрос на такт.
- **`ListFIPRefs` и `ListFIPRefsByAddresses`:** только строки в нетерминальных состояниях (`state NOT IN done, failed, occupied`)
с непустым `fip_id`: у терминальных FIP уже отвязан (агрегация, возврат по лизингу, отмена и `occupied` делают это до записи
состояния). Значения `fip_id` в строках не меняются (дашборд их показывает).
### 2. Оркестратор (`internal/orchestrator/orchestrator.go`)
- Ключ защиты от дублей привязки: `assign:<id адреса>`, а не `assign:<validator>` (строка 149). Дублирующий запуск привязки
одного и того же адреса по-прежнему исключён.
- `ForceCancel`, `sweepExpiredLeases`, `aggregateAndRelease`, `SelfCheckResult`: передают id адреса в освобождение (I3).
- **`ClearQueue`/`DeleteIPs`:** отвязка FIP из `ListFIPRefs` (теперь ≤ числа валидаторов) выполняется параллельно, не более
8 одновременных вызовов; затем прямой опрос портов (`releaseValidatorPorts`) как сейчас.
- `Tick`: вызывает `ReconcileValidators` первым шагом.
### 3. HTTP (`internal/httpapi/handlers_admin.go`)
- «Очистить всё», удаление списка и отмена: контекст отвязан от отмены запроса (`context.WithoutCancel`) с собственным пределом
времени (10 минут). Разрыв соединения клиентом (в т. ч. таймаут дашборда) больше не обрывает операцию на середине.
### 4. Агент (`internal/agentcore/agentcore.go`)
- Heartbeat уходит из `pollOnce` в **отдельную горутину** со своим тикером (период `poll_interval_seconds`); останавливается по
отмене контекста. Долгие внешние проверки больше не блокируют heartbeat. Ошибки heartbeat пишутся в лог (предупреждение).
- Протокол и конфигурация агента не меняются (старый агент с новым сервером и наоборот работают).
### 5. Документация
`docs/USAGE.md`/`docs/DIAGRAMS.md` (состояния валидатора и правила освобождения), запись в истории изменений `README.md`.
## Тесты
Пишу сам (агенты тесты не делают); каждый тест должен падать без исправления.
- **db:**
- `Heartbeat` из `unreachable` с адресом даёт `assigned`, без адреса `idle`; `RegisterValidator` аналогично;
- `ReleaseFIP`/`RequeueOrFail`/`MarkFIPOccupied` старого адреса не освобождают валидатор, который уже держит другой адрес;
- освобождение `unreachable`-валидатора оставляет `unreachable`;
- `ClaimNextQueued` не выдаёт адрес валидатору с `current_ip_id`;
- `ReconcileValidators` лечит битые строки и не трогает корректные;
- `ListFIPRefs`/`ListFIPRefsByAddresses` не содержат `done`/`failed`/`occupied`.
- **orchestrator:**
- сценарий инцидента: валидатор помечен `unreachable`, держа адрес A с идущими проверками → heartbeat → такт оркестратора
**не выдаёт** ему B; после завершения A валидатор получает B;
- мёртвый валидатор (`unreachable`, лизинг истёк) не получает новых адресов;
- `ClearQueue` при 1000 строк `done` с `fip_id` и 5 активных не вызывает `Disassociate` для `done` (счётчик вызовов в обёртке OpenStack);
- защита привязки по адресу (два адреса одного валидатора, искусственно, оба привязываются);
- **случайный сценарий под нагрузкой** (20 валидаторов, 300 адресов, случайная потеря heartbeat, долгие проверки, часть адресов
с отказом привязки): после каждого такта проверяются I1–I3; в конце все адреса завершены, сбросов лизинга нет.
- **httpapi:** «Очистить всё» доживает до конца при отмене контекста запроса посередине (БД очищена, FIP отвязаны).
- **agentcore:** во время долгой проверки (цель отвечает 3 с) heartbeat уходит по расписанию; остановка по отмене контекста.
- **Общий прогон:** `go vet`, `go test -race ./...`, `scripts/run-local-e2e.sh` (с `ip_echo` и `control_api`).
## Выкладка
1. Правки control-api: сборка `bin/control-api`, образ `civ-capi`, перезапуск (БД не затрагивается; миграций нет).
Одного этого достаточно, чтобы остановить двойные выдачи и бесконечное «залипание».
2. Агент: сборка `bin/validator-agent`, коммит, раскатка Ansible-сценарием `deploy/ansible` (запускает пользователь): heartbeat в
отдельном потоке убирает ложные `unreachable`.
3. Перепроверка 42 адресов: список выгружается из снимка анализа в файл `analysis/2026-10-02_08-56_failed-addresses.txt` (уже выгружен, 42 адреса); ставятся в очередь
через `POST /api/v1/admin/ips` (или «Перепроверка» в дашборде) после выкладки.
## Приёмка на стенде
Контрольная группа из 20 адресов, затем 400 адресов (при тех же внешних целях с долгими таймаутами) с наблюдением 30 минут:
| Показатель | Критерий |
|---|---|
| `lease_expired` | 0 |
| Ошибки привязки `409` | 0 |
| Двойные выдачи (два `claimed ip` одному валидатору за <10 с) | 0 |
| Завершено каждым валидатором | отклонение от среднего не больше 15% |
| `unreachable` при долгих проверках | нет (после раскатки нового агента) |
| «Очистить всё» при >1000 строк `done` | ответ не дольше 10 с |
| Адреса `fail` | только по существу (не из-за лизинга) |
## Риски и откат
- Изменения только в запросах и логике, без миграций; схема БД и протокол агента не меняются. Откат — предыдущий образ `civ-capi`
(`docker tag`/предыдущий коммит) и прежний бинарник агента.
- Риск: условные `UPDATE` по `current_ip_id` могут оставить валидатор занятым, если строка адреса пропала. Страхует `ReconcileValidators`.
- Валидатор `unreachable` теперь не получает адресов до первого heartbeat — это намеренно; если агент жив, но heartbeat по сети
не проходит, он простаивает (раньше брал адреса и терял их).
## Не входит в эту правку
- Параллельное выполнение внешних проверок в агенте (3–4 таймаута сейчас идут подряд): сократило бы цикл с ~84 с и снизило бы
нагрузку на heartbeat; отдельное решение.
- Судьба цели `packages.ubuntu.com` (71% `partial`) и причины недоступности проберов — отдельные вопросы из анализа.
- Сокращение `fip_settle_seconds` (30 с) ради темпа.
## Вопросы к согласованию
1. Предел времени «Очистить всё» (10 минут) и число параллельных отвязок (8) — подходят?
2. Выкладывать control-api сразу после тестов (до раскатки агента) — да, как в разделе «Выкладка»?
3. 42 адреса `fail` перепроверять сразу после выкладки или вместе со следующим большим прогоном?
+165 -15
View File
@@ -10,13 +10,16 @@ package agentcore
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
neturl "net/url"
"os"
"strings"
"sync/atomic"
"time"
"cloudipvalidator/internal/apiclient"
@@ -31,6 +34,10 @@ type Agent struct {
lastHandledIPID int64
// busy is true while an assignment is being worked on; it is reported in
// the heartbeat body (informational on the control-api side).
busy atomic.Bool
// registerRetryInitial/Max govern the backoff used while waiting for a
// successful registration (see registerWithRetry): control-api may not
// be up yet at agent boot, or may come and go across a redeploy, and the
@@ -53,6 +60,13 @@ func New(cfg *config.ValidatorAgent, log *slog.Logger) *Agent {
}
}
// WithToken sets the bearer token sent to the Control API (and only to it:
// the IP-echo lookup and all check traffic use separate clients).
func (a *Agent) WithToken(token string) *Agent {
a.client.Token = token
return a
}
// Run registers with the Control API and polls forever until ctx is
// cancelled.
func (a *Agent) Run(ctx context.Context) error {
@@ -61,6 +75,15 @@ func (a *Agent) Run(ctx context.Context) error {
}
interval := time.Duration(a.cfg.PollIntervalSeconds) * time.Second
// Heartbeats run on their own schedule. Sent from the poll loop they
// stopped for as long as a slow assignment took (an address whose
// outbound targets all time out keeps the loop busy for ~40 s), which
// control-api reads as a lost validator after heartbeat_timeout_seconds.
hbCtx, stopHeartbeat := context.WithCancel(ctx)
defer stopHeartbeat()
go a.heartbeatLoop(hbCtx, interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -139,12 +162,32 @@ type checkConfigDTO struct {
Targets []string `json:"targets"`
}
func (a *Agent) pollOnce(ctx context.Context) {
if _, err := a.client.Do(ctx, "POST", "/api/v1/agents/"+a.cfg.ValidatorID+"/heartbeat", heartbeatReq{LocalState: "idle"}, nil); err != nil {
a.log.Error("heartbeat", "err", err)
return
// heartbeatLoop sends a heartbeat now and then every interval until ctx is
// cancelled.
func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
a.sendHeartbeat(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *Agent) sendHeartbeat(ctx context.Context) {
state := "idle"
if a.busy.Load() {
state = "checking"
}
if _, err := a.client.Do(ctx, "POST", "/api/v1/agents/"+a.cfg.ValidatorID+"/heartbeat", heartbeatReq{LocalState: state}, nil); err != nil && ctx.Err() == nil {
a.log.Error("heartbeat", "err", err)
}
}
func (a *Agent) pollOnce(ctx context.Context) {
var assignment assignmentResp
ok, err := a.client.Do(ctx, "GET", "/api/v1/agents/"+a.cfg.ValidatorID+"/assignment", nil, &assignment)
if err != nil {
@@ -160,6 +203,8 @@ func (a *Agent) pollOnce(ctx context.Context) {
return // already handled this IP's work this attempt
}
a.busy.Store(true)
defer a.busy.Store(false)
switch assignment.Phase {
case "awaiting_self_check":
a.handleSelfCheckAndRun(ctx, assignment)
@@ -174,18 +219,13 @@ func (a *Agent) pollOnce(ctx context.Context) {
func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignmentResp) {
a.postEvent(ctx, assignment.IPID, "config_received", "")
timeout := time.Duration(a.cfg.SelfCheck.TimeoutSeconds) * time.Second
// Each method gets the full timeout (see runSelfCheckMethods), so the
// overall budget scales with the number of methods.
timeout := time.Duration(a.cfg.SelfCheck.TimeoutSeconds) * time.Second * time.Duration(len(a.selfCheckMethods()))
selfCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
detectedIP, err := a.detectPublicIP(selfCtx)
success := err == nil && detectedIP == assignment.IPAddress
detail := "matched"
if err != nil {
detail = "ip echo request failed: " + err.Error()
} else if !success {
detail = fmt.Sprintf("egress ip %q does not match assigned fip %q", detectedIP, assignment.IPAddress)
}
detectedIP, _, detail, success := a.runSelfCheckMethods(selfCtx, assignment.IPAddress)
a.postSelfCheck(ctx, assignment.IPID, detectedIP, success, detail)
a.postEvent(ctx, assignment.IPID, "self_check_result", fmt.Sprintf(`{"success":%t}`, success))
@@ -197,7 +237,117 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
a.runChecks(ctx, assignment)
}
// detectPublicIP asks each configured IP-echo URL, in order, for the
// selfCheckMethods returns the configured methods in priority order. Configs
// built without the loader (tests) may leave the list empty; that means the
// historical behaviour, ip_echo only.
func (a *Agent) selfCheckMethods() []string {
if len(a.cfg.SelfCheck.Methods) == 0 {
return []string{config.SelfCheckIPEcho}
}
return a.cfg.SelfCheck.Methods
}
// runSelfCheckMethods tries the configured methods in priority order and
// stops at the first one that confirms assignedIP. A method that gives no
// answer and one that reports a different address are treated alike: the
// next method is tried, since either may be a limitation of that method
// (e.g. control-api reached over the internal network sees a private
// address) rather than proof the floating IP is not attached. The check
// fails only when no method confirms, and detail then carries the reason
// from every method. detectedIP is the matching address on success, else the
// last address any method reported (may be empty).
//
// Every method runs under its own self_check.timeout_seconds: with one shared
// deadline a hung first method (priority control_api) would use it all up and
// the fallback would never get a chance to answer.
func (a *Agent) runSelfCheckMethods(ctx context.Context, assignedIP string) (detectedIP, method, detail string, ok bool) {
var reasons []string
perMethod := time.Duration(a.cfg.SelfCheck.TimeoutSeconds) * time.Second
for _, m := range a.selfCheckMethods() {
mctx, cancel := ctx, context.CancelFunc(func() {})
if perMethod > 0 {
mctx, cancel = context.WithTimeout(ctx, perMethod)
}
var ip string
var err error
switch m {
case config.SelfCheckControlAPI:
ip, err = a.detectViaControlAPI(mctx)
case config.SelfCheckIPEcho:
ip, err = a.detectViaIPEcho(mctx)
if err != nil {
err = fmt.Errorf("ip echo request failed: %w", err)
}
default:
err = fmt.Errorf("unknown self-check method")
}
cancel()
if err != nil {
reasons = append(reasons, m+": "+err.Error())
continue
}
if ip == assignedIP {
return ip, m, fmt.Sprintf("matched (%s)", m), true
}
detectedIP = ip
reason := fmt.Sprintf("%s: egress ip %q does not match assigned fip %q", m, ip, assignedIP)
if m == config.SelfCheckControlAPI && isLocalAddr(ip) {
reason += " (control-api sees a private address; it is reachable over the internal network, self-check via control_api is not possible, use ip_echo)"
}
reasons = append(reasons, reason)
}
if len(reasons) == 0 {
reasons = append(reasons, "no self-check methods configured")
}
return detectedIP, "", strings.Join(reasons, "; "), false
}
// isLocalAddr reports whether ip is a private, loopback or link-local
// address, i.e. one that can never be a floating IP.
func isLocalAddr(ip string) bool {
parsed := net.ParseIP(ip)
return parsed != nil && (parsed.IsPrivate() || parsed.IsLoopback() || parsed.IsLinkLocalUnicast())
}
// detectViaControlAPI asks control-api which source address it sees for this
// validator. It is only meaningful when control-api is reached over the
// external network, where the floating IP is the visible source (see
// config.SelfCheckCfg).
//
// Every call dials a brand-new TCP connection through a dedicated transport
// with keep-alives off: a connection opened before the floating IP was
// attached (heartbeat, assignment polling) keeps its old NAT state and would
// keep reporting the previous address, so the shared apiclient must not be
// used. The endpoint is open, so no agent token is sent.
func (a *Agent) detectViaControlAPI(ctx context.Context) (string, error) {
url := strings.TrimRight(a.cfg.ControlAPIURL, "/") + "/api/v1/agents/" + neturl.PathEscape(a.cfg.ValidatorID) + "/observed-ip"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
transport := &http.Transport{DisableKeepAlives: true}
defer transport.CloseIdleConnections()
resp, err := (&http.Client{Transport: transport}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
}
var body struct {
IP string `json:"ip"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&body); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if net.ParseIP(body.IP) == nil {
return "", fmt.Errorf("response is not a valid IP: %q", body.IP)
}
return body.IP, nil
}
// detectViaIPEcho asks each configured IP-echo URL, in order, for the
// address this validator is currently seen egressing from, returning the
// first one that answers with a parseable IP. These must be resources
// genuinely outside the cloud project (see config.SelfCheckCfg) — OpenStack
@@ -205,7 +355,7 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
// network, so anything reachable over the project's internal network would
// report the validator's private address instead, regardless of whether
// the floating IP is correctly attached.
func (a *Agent) detectPublicIP(ctx context.Context) (string, error) {
func (a *Agent) detectViaIPEcho(ctx context.Context) (string, error) {
var lastErr error
for _, url := range a.cfg.SelfCheck.IPEchoURLs {
ip, err := fetchIPEcho(ctx, url)
+31
View File
@@ -98,3 +98,34 @@ func TestRegisterWithRetryStopsOnCancel(t *testing.T) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
// TestTokenGoesOnlyToControlAPI: the bearer token authenticates calls to
// control-api, and must never leak to the external IP-echo service.
func TestTokenGoesOnlyToControlAPI(t *testing.T) {
var apiAuth, echoAuth atomic.Value
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiAuth.Store(r.Header.Get("Authorization"))
w.Write([]byte(`{"ok":true,"poll_interval_seconds":5}`))
}))
defer api.Close()
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
echoAuth.Store(r.Header.Get("Authorization"))
w.Write([]byte("203.0.113.7"))
}))
defer echo.Close()
a := New(&config.ValidatorAgent{ValidatorID: "val-1", ControlAPIURL: api.URL}, testLogger()).WithToken("agent-secret")
ctx := context.Background()
if err := a.registerWithRetry(ctx); err != nil {
t.Fatalf("register: %v", err)
}
if _, err := fetchIPEcho(ctx, echo.URL); err != nil {
t.Fatalf("fetchIPEcho: %v", err)
}
if got, _ := apiAuth.Load().(string); got != "Bearer agent-secret" {
t.Fatalf("control-api Authorization = %q, want bearer token", got)
}
if got, _ := echoAuth.Load().(string); got != "" {
t.Fatalf("IP-echo request carried Authorization %q, want none", got)
}
}
+72
View File
@@ -0,0 +1,72 @@
package agentcore
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"cloudipvalidator/internal/config"
)
// While the agent is busy with a slow assignment (an address whose outbound
// targets time out keeps it occupied for tens of seconds) it must keep sending
// heartbeats; control-api marks a validator that stays silent for
// heartbeat_timeout_seconds as unreachable.
func TestHeartbeatContinuesDuringSlowChecks(t *testing.T) {
slowTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3500 * time.Millisecond)
}))
defer slowTarget.Close()
var heartbeats, assignments int32
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/agents/register", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"ok":true}`)
})
mux.HandleFunc("POST /api/v1/agents/val-1/heartbeat", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&heartbeats, 1)
fmt.Fprint(w, `{"ok":true}`)
})
mux.HandleFunc("GET /api/v1/agents/val-1/assignment", func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&assignments, 1) > 1 {
w.WriteHeader(http.StatusNoContent)
return
}
fmt.Fprintf(w, `{"ip_id":1,"ip_address":"1.1.1.1","phase":"checking","check_config":[{"type":"https","targets":[%q]}]}`, slowTarget.URL)
})
mux.HandleFunc("POST /api/v1/agents/val-1/results", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"ok":true}`) })
mux.HandleFunc("POST /api/v1/agents/val-1/complete", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"ok":true}`) })
capi := httptest.NewServer(mux)
defer capi.Close()
a := New(&config.ValidatorAgent{
ValidatorID: "val-1", ControlAPIURL: capi.URL, PollIntervalSeconds: 1,
Checks: config.AgentChecks{HTTPSTimeoutSeconds: 10, ICMPTimeoutSeconds: 1, ICMPCount: 1},
}, testLogger())
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { _ = a.Run(ctx); close(done) }()
time.Sleep(3 * time.Second) // the slow check (3.5 s) is still running
during := atomic.LoadInt32(&heartbeats)
cancel()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Run did not stop after the context was cancelled")
}
// One per second plus the first: 3-4 in 3 s. With heartbeats in the poll
// loop there is exactly one, sent before the slow assignment started.
if during < 3 {
t.Fatalf("%d heartbeats in 3 s while a check was running, want at least 3", during)
}
if atomic.LoadInt32(&assignments) < 1 {
t.Fatal("the assignment was never fetched, the test did not exercise a busy agent")
}
}
+232
View File
@@ -0,0 +1,232 @@
package agentcore
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"cloudipvalidator/internal/config"
)
// echoServer answers every request with body (an IP-echo stand-in).
func echoServer(t *testing.T, body string) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, body)
}))
t.Cleanup(ts.Close)
return ts
}
// controlAPIServer plays control-api's observed-ip route: it answers with ip
// (status 200) or with the given error status when ip is empty.
func controlAPIServer(t *testing.T, ip string, status int) *httptest.Server {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/agents/val-1/observed-ip" {
http.NotFound(w, r)
return
}
if ip == "" {
w.WriteHeader(status)
return
}
fmt.Fprintf(w, `{"ip":%q,"source":"remote_addr"}`, ip)
}))
t.Cleanup(ts.Close)
return ts
}
func selfCheckAgent(controlAPIURL string, methods []string, echoURLs ...string) *Agent {
return &Agent{
cfg: &config.ValidatorAgent{
ValidatorID: "val-1",
ControlAPIURL: controlAPIURL,
SelfCheck: config.SelfCheckCfg{TimeoutSeconds: 2, Methods: methods, IPEchoURLs: echoURLs},
},
log: testLogger(),
}
}
func TestSelfCheckControlAPIFirstWins(t *testing.T) {
capi := controlAPIServer(t, "1.2.3.4", 0)
var echoCalls int32
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&echoCalls, 1)
fmt.Fprint(w, "1.2.3.4")
}))
defer echo.Close()
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
ip, method, detail, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
if !ok || ip != "1.2.3.4" || method != config.SelfCheckControlAPI || detail != "matched (control_api)" {
t.Fatalf("got ip=%q method=%q detail=%q ok=%v", ip, method, detail, ok)
}
if n := atomic.LoadInt32(&echoCalls); n != 0 {
t.Fatalf("ip_echo was called %d times although control_api already confirmed", n)
}
}
// Any one confirming method is enough: control-api answers with a different
// address, ip_echo confirms.
func TestSelfCheckFallsThroughOnMismatch(t *testing.T) {
capi := controlAPIServer(t, "5.5.5.5", 0)
echo := echoServer(t, "1.2.3.4")
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
ip, method, _, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
if !ok || ip != "1.2.3.4" || method != config.SelfCheckIPEcho {
t.Fatalf("got ip=%q method=%q ok=%v, want a pass via ip_echo", ip, method, ok)
}
}
// An old control-api without the route (404) or a failing one (5xx) must not
// stop the self-check: the next method decides.
func TestSelfCheckFallsThroughOnControlAPIError(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
capi := controlAPIServer(t, "", status)
echo := echoServer(t, "1.2.3.4")
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
_, method, _, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
if !ok || method != config.SelfCheckIPEcho {
t.Fatalf("status %d: method=%q ok=%v, want a pass via ip_echo", status, method, ok)
}
}
}
func TestSelfCheckFailsWhenNoMethodConfirms(t *testing.T) {
capi := controlAPIServer(t, "10.0.0.5", 0) // private address: internal-network case
echo := echoServer(t, "6.6.6.6")
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
ip, method, detail, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
if ok || method != "" {
t.Fatalf("expected a failure, got ok=%v method=%q", ok, method)
}
if ip != "6.6.6.6" {
t.Fatalf("detected ip = %q, want the last reported address 6.6.6.6", ip)
}
for _, want := range []string{
`control_api: egress ip "10.0.0.5" does not match assigned fip "1.2.3.4"`,
"private address", // the hint for the internal-network case
`ip_echo: egress ip "6.6.6.6" does not match`,
} {
if !strings.Contains(detail, want) {
t.Fatalf("detail %q does not contain %q", detail, want)
}
}
}
func TestSelfCheckPrivateHintOnlyForControlAPI(t *testing.T) {
echo := echoServer(t, "10.1.1.1")
a := selfCheckAgent("http://unused", []string{config.SelfCheckIPEcho}, echo.URL)
_, _, detail, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
if ok || strings.Contains(detail, "private address") {
t.Fatalf("ok=%v detail=%q: the control_api hint must not appear for ip_echo", ok, detail)
}
}
// With nothing configured (a config built without the loader) the agent
// behaves as before: ip_echo only, control-api is never asked.
func TestSelfCheckDefaultsToIPEcho(t *testing.T) {
var capiCalls int32
capi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&capiCalls, 1)
}))
defer capi.Close()
echo := echoServer(t, "1.2.3.4")
a := selfCheckAgent(capi.URL, nil, echo.URL)
_, method, _, ok := a.runSelfCheckMethods(context.Background(), "1.2.3.4")
if !ok || method != config.SelfCheckIPEcho || atomic.LoadInt32(&capiCalls) != 0 {
t.Fatalf("method=%q ok=%v control-api calls=%d", method, ok, atomic.LoadInt32(&capiCalls))
}
}
// A hung control-api must not use up the time of the fallback: each method
// has its own timeout.
func TestSelfCheckHungControlAPIDoesNotStarveFallback(t *testing.T) {
release := make(chan struct{})
capi := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
}))
defer capi.Close()
defer close(release)
echo := echoServer(t, "1.2.3.4")
a := selfCheckAgent(capi.URL, []string{config.SelfCheckControlAPI, config.SelfCheckIPEcho}, echo.URL)
a.cfg.SelfCheck.TimeoutSeconds = 1
start := time.Now()
// The outer context mirrors handleSelfCheckAndRun: timeout x methods.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, method, detail, ok := a.runSelfCheckMethods(ctx, "1.2.3.4")
if !ok || method != config.SelfCheckIPEcho {
t.Fatalf("method=%q ok=%v detail=%q, want a pass via ip_echo after control_api timed out", method, ok, detail)
}
if elapsed := time.Since(start); elapsed > 1900*time.Millisecond {
t.Fatalf("took %s: the hung method consumed the fallback's time", elapsed)
}
}
// Every control-api request must use a new TCP connection: a connection
// opened before the floating IP was attached would report the old address.
func TestDetectViaControlAPIDialsNewConnectionEachTime(t *testing.T) {
var conns int32
ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"ip":"1.2.3.4","source":"remote_addr"}`)
}))
ts.Config.ConnState = func(_ net.Conn, s http.ConnState) {
if s == http.StateNew {
atomic.AddInt32(&conns, 1)
}
}
ts.Start()
defer ts.Close()
a := selfCheckAgent(ts.URL, nil)
for i := 0; i < 3; i++ {
if _, err := a.detectViaControlAPI(context.Background()); err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
if n := atomic.LoadInt32(&conns); n != 3 {
t.Fatalf("3 calls opened %d connections, want 3 (no keep-alive reuse)", n)
}
}
func TestDetectViaControlAPIRejectsBadAnswers(t *testing.T) {
for name, body := range map[string]string{"not json": "oops", "not an ip": `{"ip":"abc"}`, "empty": `{}`} {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }))
a := selfCheckAgent(ts.URL, nil)
if ip, err := a.detectViaControlAPI(context.Background()); err == nil {
t.Fatalf("%s: expected an error, got %q", name, ip)
}
ts.Close()
}
}
// The agent token must never be sent on this request (the route is open and
// the token is meant for control-api writes only).
func TestDetectViaControlAPISendsNoToken(t *testing.T) {
var auth string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
fmt.Fprint(w, `{"ip":"1.2.3.4"}`)
}))
defer ts.Close()
a := selfCheckAgent(ts.URL, nil)
if _, err := a.detectViaControlAPI(context.Background()); err != nil {
t.Fatal(err)
}
if auth != "" {
t.Fatalf("Authorization header sent: %q", auth)
}
}
+6
View File
@@ -17,6 +17,9 @@ import (
type Client struct {
BaseURL string
HTTPClient *http.Client
// Token, when non-empty, is sent as "Authorization: Bearer <Token>" on
// every request to the Control API.
Token string
}
func New(baseURL string, timeout time.Duration) *Client {
@@ -42,6 +45,9 @@ func (c *Client) Do(ctx context.Context, method, path string, body, out interfac
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
+30
View File
@@ -0,0 +1,30 @@
package apiclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestDoSetsBearerOnlyWhenTokenSet(t *testing.T) {
var got []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("Authorization"))
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
c := New(ts.URL, 5*time.Second)
if _, err := c.Do(context.Background(), http.MethodGet, "/x", nil, nil); err != nil {
t.Fatalf("do without token: %v", err)
}
c.Token = "secret-agent-token"
if _, err := c.Do(context.Background(), http.MethodPost, "/x", map[string]string{"a": "b"}, nil); err != nil {
t.Fatalf("do with token: %v", err)
}
if len(got) != 2 || got[0] != "" || got[1] != "Bearer secret-agent-token" {
t.Fatalf("Authorization headers = %q, want [\"\" \"Bearer secret-agent-token\"]", got)
}
}
+110 -7
View File
@@ -25,6 +25,15 @@ type ControlAPI struct {
Targets map[string][]string `yaml:"targets"`
Inbound InboundConfig `yaml:"inbound_checks"`
IPAddresses []string `yaml:"ip_addresses"`
Auth ControlAPIAuth `yaml:"auth"`
}
// ControlAPIAuth names the environment variables control-api reads its two
// static bearer tokens from. Values are never stored in the config. An unset
// (empty) token leaves that access level open, with a startup warning.
type ControlAPIAuth struct {
AdminTokenEnv string `yaml:"admin_token_env"` // default CONTROL_API_ADMIN_TOKEN — protects /api/v1/admin/*
AgentTokenEnv string `yaml:"agent_token_env"` // default CONTROL_API_AGENT_TOKEN — protects agent/prober write calls
}
type ServerConfig struct {
@@ -60,6 +69,17 @@ type OpenStackConfig struct {
UsernameEnv string `yaml:"username_env"` // default OS_USERNAME — used when auth_method: password
UserDomainNameEnv string `yaml:"user_domain_name_env"` // default OS_USER_DOMAIN_NAME
PasswordEnv string `yaml:"password_env"` // default OS_PASSWORD
// ListPageSize is how many floating IPs one Neutron list request asks
// for (the scan reads the project page by page). Default 200.
ListPageSize int `yaml:"list_page_size"`
// RequestTimeoutSeconds bounds every single HTTP request to Keystone and
// Neutron. Default 60.
RequestTimeoutSeconds int `yaml:"request_timeout_seconds"`
// ListPageRetries is how many times one failed page of the listing is
// retried (backoff 1s,2s,4s,...) on network errors, 5xx and 429. Default
// 5; a negative value disables retries.
ListPageRetries int `yaml:"list_page_retries"`
}
type OrchestratorConfig struct {
@@ -84,6 +104,9 @@ type OrchestratorConfig struct {
// POST /api/v1/admin/ips/scan or the dashboard's "Scan Floating IPs"
// button either way.
FIPScanIntervalSeconds int `yaml:"fip_scan_interval_seconds"`
// FIPScanTimeoutSeconds is the overall deadline of one background
// floating-IP scan (clear + paged read + enqueue). Default 1800.
FIPScanTimeoutSeconds int `yaml:"fip_scan_timeout_seconds"`
}
type AggregationConfig struct {
@@ -155,6 +178,21 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
if c.OpenStack.PasswordEnv == "" {
c.OpenStack.PasswordEnv = "OS_PASSWORD"
}
if c.OpenStack.ListPageSize == 0 {
c.OpenStack.ListPageSize = 200
}
if c.OpenStack.RequestTimeoutSeconds == 0 {
c.OpenStack.RequestTimeoutSeconds = 60
}
if c.OpenStack.ListPageRetries == 0 {
c.OpenStack.ListPageRetries = 5
}
if c.Auth.AdminTokenEnv == "" {
c.Auth.AdminTokenEnv = "CONTROL_API_ADMIN_TOKEN"
}
if c.Auth.AgentTokenEnv == "" {
c.Auth.AgentTokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.Orchestrator.PollIntervalSeconds == 0 {
c.Orchestrator.PollIntervalSeconds = 5
}
@@ -176,30 +214,49 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
if c.Orchestrator.HeartbeatTimeoutSeconds == 0 {
c.Orchestrator.HeartbeatTimeoutSeconds = 30
}
if c.Orchestrator.FIPScanTimeoutSeconds == 0 {
c.Orchestrator.FIPScanTimeoutSeconds = 1800
}
return &c, nil
}
// ---- validator-agent ----
type ValidatorAgent struct {
ValidatorID string `yaml:"validator_id"`
ControlAPIURL string `yaml:"control_api_url"`
ValidatorID string `yaml:"validator_id"`
ControlAPIURL string `yaml:"control_api_url"`
// ControlAPITokenEnv is the name of the env var holding the agent bearer
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
ControlAPITokenEnv string `yaml:"control_api_token_env"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
SelfCheck SelfCheckCfg `yaml:"self_check"`
Checks AgentChecks `yaml:"checks"`
}
// Self-check methods accepted in SelfCheckCfg.Methods.
const (
SelfCheckIPEcho = "ip_echo"
SelfCheckControlAPI = "control_api"
)
// SelfCheckCfg configures how the agent confirms its egress actually flows
// through the newly assigned floating IP. This must query a resource
// genuinely outside the cloud project: OpenStack only applies floating-IP
// SNAT to traffic leaving via the external/provider network, so any
// in-project resource (including control-api, if it's reachable over the
// project's internal network) would see the validator's private address
// instead — a false negative that never changes. IPEchoURLs are tried in
// order (falling through to the next on error/timeout, not on a genuine
// mismatch) until one returns a parseable IP.
// instead — a false negative that never changes. The control_api method is
// therefore only valid when control-api is reached over the external network
// (hosted outside the cloud); then it sees the floating IP as the source.
//
// Methods are tried in priority order and the self-check passes as soon as
// any one confirms the address; the next method is tried both when one gives
// no answer and when it reports a different address. IPEchoURLs are tried in
// order within ip_echo (falling through to the next on error/timeout, not on
// a genuine mismatch) until one returns a parseable IP.
type SelfCheckCfg struct {
TimeoutSeconds int `yaml:"timeout_seconds"`
Methods []string `yaml:"methods"`
IPEchoURLs []string `yaml:"ip_echo_urls"`
}
@@ -223,9 +280,20 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
if c.PollIntervalSeconds == 0 {
c.PollIntervalSeconds = 5
}
if c.ControlAPITokenEnv == "" {
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.SelfCheck.TimeoutSeconds == 0 {
c.SelfCheck.TimeoutSeconds = 10
}
if len(c.SelfCheck.Methods) == 0 {
c.SelfCheck.Methods = []string{SelfCheckIPEcho}
}
for _, m := range c.SelfCheck.Methods {
if m != SelfCheckIPEcho && m != SelfCheckControlAPI {
return nil, fmt.Errorf("self_check.methods: unknown method %q (allowed: %s, %s)", m, SelfCheckIPEcho, SelfCheckControlAPI)
}
}
if len(c.SelfCheck.IPEchoURLs) == 0 {
c.SelfCheck.IPEchoURLs = []string{"https://api.ipify.org", "https://ifconfig.me/ip"}
}
@@ -253,8 +321,11 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
// ---- prober ----
type Prober struct {
SiteID string `yaml:"site_id"`
ControlAPIURL string `yaml:"control_api_url"`
SiteID string `yaml:"site_id"`
ControlAPIURL string `yaml:"control_api_url"`
// ControlAPITokenEnv is the name of the env var holding the agent bearer
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
ControlAPITokenEnv string `yaml:"control_api_token_env"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
Checks ProberChecks `yaml:"checks"`
}
@@ -273,6 +344,9 @@ func LoadProber(path string) (*Prober, error) {
if c.PollIntervalSeconds == 0 {
c.PollIntervalSeconds = 5
}
if c.ControlAPITokenEnv == "" {
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.Checks.TCPTimeoutSeconds == 0 {
c.Checks.TCPTimeoutSeconds = 5
}
@@ -300,11 +374,25 @@ type AdminDashboard struct {
Server ServerConfig `yaml:"server"`
ControlAPI DashboardControlAPIConfig `yaml:"control_api"`
Overview DashboardOverviewConfig `yaml:"overview"`
Auth DashboardAuthConfig `yaml:"auth"`
}
// DashboardAuthConfig names the env vars holding the single administrator's
// login, password and the session-cookie HMAC key. If username or password
// is empty at runtime, login is not required (with a startup warning).
type DashboardAuthConfig struct {
UsernameEnv string `yaml:"username_env"` // default ADMIN_DASHBOARD_USERNAME
PasswordEnv string `yaml:"password_env"` // default ADMIN_DASHBOARD_PASSWORD
SessionSecretEnv string `yaml:"session_secret_env"` // default ADMIN_DASHBOARD_SESSION_SECRET
SessionTTLMinutes int `yaml:"session_ttl_minutes"` // default 480
}
type DashboardControlAPIConfig struct {
BaseURL string `yaml:"base_url"`
TimeoutSeconds int `yaml:"timeout_seconds"`
// TokenEnv is the name of the env var holding control-api's admin bearer
// token (default ADMIN_DASHBOARD_CONTROL_API_TOKEN).
TokenEnv string `yaml:"token_env"`
}
// DashboardOverviewConfig configures the overview page's "текущая
@@ -331,6 +419,21 @@ func LoadAdminDashboard(path string) (*AdminDashboard, error) {
if c.ControlAPI.TimeoutSeconds == 0 {
c.ControlAPI.TimeoutSeconds = 10
}
if c.ControlAPI.TokenEnv == "" {
c.ControlAPI.TokenEnv = "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
}
if c.Auth.UsernameEnv == "" {
c.Auth.UsernameEnv = "ADMIN_DASHBOARD_USERNAME"
}
if c.Auth.PasswordEnv == "" {
c.Auth.PasswordEnv = "ADMIN_DASHBOARD_PASSWORD"
}
if c.Auth.SessionSecretEnv == "" {
c.Auth.SessionSecretEnv = "ADMIN_DASHBOARD_SESSION_SECRET"
}
if c.Auth.SessionTTLMinutes == 0 {
c.Auth.SessionTTLMinutes = 480
}
if c.Overview.LastCompletedCount == 0 {
c.Overview.LastCompletedCount = 20
}
+121
View File
@@ -0,0 +1,121 @@
package config
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func TestLoadControlAPIScanDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "c.yaml")
if err := os.WriteFile(path, []byte("server:\n listen_addr: \":8080\"\n"), 0o600); err != nil {
t.Fatal(err)
}
c, err := LoadControlAPI(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if c.OpenStack.ListPageSize != 200 || c.OpenStack.RequestTimeoutSeconds != 60 ||
c.OpenStack.ListPageRetries != 5 || c.Orchestrator.FIPScanTimeoutSeconds != 1800 {
t.Fatalf("unexpected defaults: openstack=%+v orchestrator=%+v", c.OpenStack, c.Orchestrator)
}
}
func TestLoadControlAPIScanOverrides(t *testing.T) {
path := filepath.Join(t.TempDir(), "c.yaml")
yaml := "openstack:\n list_page_size: 50\n request_timeout_seconds: 10\n list_page_retries: -1\n" +
"orchestrator:\n fip_scan_timeout_seconds: 99\n"
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
c, err := LoadControlAPI(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if c.OpenStack.ListPageSize != 50 || c.OpenStack.RequestTimeoutSeconds != 10 ||
c.OpenStack.ListPageRetries != -1 || c.Orchestrator.FIPScanTimeoutSeconds != 99 {
t.Fatalf("overrides lost: openstack=%+v orchestrator=%+v", c.OpenStack, c.Orchestrator)
}
}
// The shipped example must load and carry the scan settings, and the rxprod
// copy must stay byte-identical to it.
func TestControlAPIExampleConfigs(t *testing.T) {
c, err := LoadControlAPI("../../configs/control-api.example.yaml")
if err != nil {
t.Fatalf("load example: %v", err)
}
if c.OpenStack.ListPageSize != 200 || c.Orchestrator.FIPScanTimeoutSeconds != 1800 {
t.Fatalf("example scan settings: %+v %+v", c.OpenStack, c.Orchestrator)
}
a, err := os.ReadFile("../../configs/control-api.example.yaml")
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile("../../rxprod-compose/sources/control-api.example.yaml")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(a, b) {
t.Fatalf("rxprod-compose/sources/control-api.example.yaml differs from configs/control-api.example.yaml")
}
if _, err := LoadControlAPI("../../deploy/docker/control-api/control-api.docker.example.yaml"); err != nil {
t.Fatalf("load docker example: %v", err)
}
}
func writeAgentConfig(t *testing.T, selfCheck string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "agent.yaml")
body := "validator_id: v1\ncontrol_api_url: http://x\nself_check:\n" + selfCheck
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadValidatorAgentSelfCheckMethods(t *testing.T) {
// No methods key: the historical behaviour, ip_echo only.
c, err := LoadValidatorAgent(writeAgentConfig(t, " timeout_seconds: 5\n"))
if err != nil {
t.Fatalf("load: %v", err)
}
if len(c.SelfCheck.Methods) != 1 || c.SelfCheck.Methods[0] != SelfCheckIPEcho {
t.Fatalf("default methods = %v, want [ip_echo]", c.SelfCheck.Methods)
}
// Order is the priority and must be kept.
c, err = LoadValidatorAgent(writeAgentConfig(t, " methods: [control_api, ip_echo]\n"))
if err != nil {
t.Fatalf("load: %v", err)
}
if got := c.SelfCheck.Methods; len(got) != 2 || got[0] != SelfCheckControlAPI || got[1] != SelfCheckIPEcho {
t.Fatalf("methods = %v, want [control_api ip_echo]", got)
}
// An unknown method is a configuration error, not a silent no-op.
if _, err := LoadValidatorAgent(writeAgentConfig(t, " methods: [control_api, ipecho]\n")); err == nil {
t.Fatal("expected an error for an unknown self-check method")
}
}
// The shipped agent example must load, and the rxprod copy must stay
// byte-identical to it.
func TestValidatorAgentExampleConfig(t *testing.T) {
c, err := LoadValidatorAgent("../../configs/validator-agent.example.yaml")
if err != nil {
t.Fatalf("load example: %v", err)
}
if got := c.SelfCheck.Methods; len(got) != 2 || got[0] != SelfCheckControlAPI {
t.Fatalf("example methods = %v", got)
}
a, _ := os.ReadFile("../../configs/validator-agent.example.yaml")
b, err := os.ReadFile("../../rxprod-compose/sources/validator-agent.example.yaml")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(a, b) {
t.Fatal("rxprod-compose/sources/validator-agent.example.yaml differs from configs/validator-agent.example.yaml")
}
}
+392
View File
@@ -0,0 +1,392 @@
package dashboard
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"log/slog"
"net"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"sync"
"time"
)
const (
sessionCookieName = "session"
defaultSessionTTL = 8 * time.Hour
// Brute-force throttle: maxLoginFailures failures from one client IP
// within loginFailureWindow lock that IP out until the oldest failure
// leaves the window.
maxLoginFailures = 5
loginFailureWindow = 10 * time.Minute
)
// authState holds everything the login/session machinery needs. A nil-safe
// zero value is never used: Server.auth is always set by New, with
// enabled=false when no credentials were configured.
type authState struct {
enabled bool
user [sha256.Size]byte // sha256(username)
pass [sha256.Size]byte // sha256(password)
key []byte // HMAC key for session cookies
ttl time.Duration
now func() time.Time
throttle *loginThrottle
}
func newAuthState(cfg Config, log *slog.Logger) *authState {
a := &authState{now: time.Now, throttle: newLoginThrottle(), ttl: cfg.SessionTTL}
if a.ttl <= 0 {
a.ttl = defaultSessionTTL
}
if cfg.Username == "" || cfg.Password == "" {
log.Warn("dashboard login is disabled: username/password are not set, anyone who can reach this port has full access")
return a
}
a.enabled = true
a.user = sha256.Sum256([]byte(cfg.Username))
a.pass = sha256.Sum256([]byte(cfg.Password))
if cfg.SessionSecret != "" {
a.key = []byte(cfg.SessionSecret)
} else {
a.key = make([]byte, 32)
if _, err := rand.Read(a.key); err != nil {
panic("dashboard: crypto/rand failed: " + err.Error())
}
log.Warn("dashboard session secret is not set: using a random one, sessions are reset on every restart")
}
return a
}
// checkCredentials compares both fields in constant time and always
// evaluates both, so neither the length nor which field was wrong leaks.
func (a *authState) checkCredentials(user, pass string) bool {
u := sha256.Sum256([]byte(user))
p := sha256.Sum256([]byte(pass))
uOK := subtle.ConstantTimeCompare(u[:], a.user[:])
pOK := subtle.ConstantTimeCompare(p[:], a.pass[:])
return uOK&pOK == 1
}
// ---- session cookie ----
type sessionPayload struct {
User string `json:"u"`
Exp int64 `json:"exp"`
}
func (a *authState) sign(msg string) []byte {
m := hmac.New(sha256.New, a.key)
m.Write([]byte(msg))
return m.Sum(nil)
}
func (a *authState) issue(user string) (value string, exp time.Time) {
exp = a.now().Add(a.ttl)
raw, _ := json.Marshal(sessionPayload{User: user, Exp: exp.Unix()})
p := base64.RawURLEncoding.EncodeToString(raw)
return p + "." + base64.RawURLEncoding.EncodeToString(a.sign(p)), exp
}
// verify returns the session's user if value is an untampered, unexpired
// cookie issued with this server's key.
func (a *authState) verify(value string) (string, bool) {
p, sig, ok := strings.Cut(value, ".")
if !ok {
return "", false
}
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil || !hmac.Equal(got, a.sign(p)) {
return "", false
}
raw, err := base64.RawURLEncoding.DecodeString(p)
if err != nil {
return "", false
}
var sp sessionPayload
if json.Unmarshal(raw, &sp) != nil || sp.User == "" || a.now().Unix() >= sp.Exp {
return "", false
}
return sp.User, true
}
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (a *authState) setCookie(w http.ResponseWriter, r *http.Request, user string) {
value, exp := a.issue(user)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: value, Path: "/", Expires: exp,
MaxAge: int(a.ttl.Seconds()), HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
})
}
func clearCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
})
}
// ---- request context ----
type ctxKey struct{}
func userFromRequest(r *http.Request) string {
u, _ := r.Context().Value(ctxKey{}).(string)
return u
}
// ---- middleware ----
// authMiddleware gates every route except the login page, logout and static
// assets. With login disabled it is a pass-through (no CSRF check either).
func (s *Server) authMiddleware(next http.Handler) http.Handler {
a := s.auth
if !a.enabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !safeMethod(r.Method) && !sameOrigin(r) {
s.Log.Warn("rejected cross-origin request", "method", r.Method, "path", r.URL.Path)
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
if isOpenPath(r) {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(sessionCookieName); err == nil {
if user, ok := a.verify(c.Value); ok {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, user)))
return
}
}
// htmx polls fragments in the background: answer with an
// HX-Redirect instead of a redirect whose login page would be
// swapped into the fragment.
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
target := "/login"
if r.Method == http.MethodGet {
if n := r.URL.RequestURI(); n != "/" {
target += "?next=" + url.QueryEscape(n)
}
}
http.Redirect(w, r, target, http.StatusSeeOther)
})
}
func isOpenPath(r *http.Request) bool {
switch r.URL.Path {
case "/login":
return r.Method == http.MethodGet || r.Method == http.MethodPost
case "/logout":
return r.Method == http.MethodPost
}
return strings.HasPrefix(r.URL.Path, "/static/") && (r.Method == http.MethodGet || r.Method == http.MethodHead)
}
func safeMethod(m string) bool {
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}
// sameOrigin implements the CSRF check: Origin (or, when absent, Referer)
// must name this very host. Browsers always send Origin on cross-site
// POSTs; a request with neither header is refused.
func sameOrigin(r *http.Request) bool {
h := r.Header.Get("Origin")
if h == "" {
h = r.Header.Get("Referer")
}
if h == "" || h == "null" {
return false
}
u, err := url.Parse(h)
if err != nil || u.Host == "" {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
// safeNext returns next only if it is a same-origin relative path, so the
// post-login redirect can never leave the site.
func safeNext(next string) string {
if next == "" || next[0] != '/' || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/"
}
for _, c := range next {
if c < 0x20 || c == 0x7f || c == '\\' {
return "/"
}
}
u, err := url.Parse(next)
if err != nil || u.Scheme != "" || u.Host != "" {
return "/"
}
return next
}
// ---- brute-force throttle ----
type loginThrottle struct {
mu sync.Mutex
failures map[string][]time.Time
}
func newLoginThrottle() *loginThrottle {
return &loginThrottle{failures: map[string][]time.Time{}}
}
// prune drops expired failures (all clients) — caller holds mu. The map is
// bounded by the number of distinct IPs that failed in the last window.
func (t *loginThrottle) prune(now time.Time) {
for ip, fs := range t.failures {
i := 0
for i < len(fs) && now.Sub(fs[i]) >= loginFailureWindow {
i++
}
if i == len(fs) {
delete(t.failures, ip)
} else if i > 0 {
t.failures[ip] = fs[i:]
}
}
}
// blocked reports whether ip is locked out, and for how long.
func (t *loginThrottle) blocked(ip string, now time.Time) (bool, time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
t.prune(now)
fs := t.failures[ip]
if len(fs) < maxLoginFailures {
return false, 0
}
return true, fs[0].Add(loginFailureWindow).Sub(now)
}
func (t *loginThrottle) fail(ip string, now time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
t.prune(now)
t.failures[ip] = append(t.failures[ip], now)
}
func (t *loginThrottle) clear(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.failures, ip)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// ---- handlers ----
type loginPageData struct {
Error string
Next string
}
func (s *Server) renderLogin(w http.ResponseWriter, status int, data loginPageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := s.tmpl.ExecuteTemplate(w, "login_page", data); err != nil {
s.Log.Error("render login", "err", err)
}
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if !s.auth.enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if c, err := r.Cookie(sessionCookieName); err == nil {
if _, ok := s.auth.verify(c.Value); ok {
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
return
}
}
s.renderLogin(w, http.StatusOK, loginPageData{Next: safeNext(r.URL.Query().Get("next"))})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
a := s.auth
if !a.enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
next := safeNext(r.PostForm.Get("next"))
ip := clientIP(r)
now := a.now()
if blocked, wait := a.throttle.blocked(ip, now); blocked {
secs := int(wait.Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(secs))
s.Log.Warn("login throttled", "remote", ip)
s.renderLogin(w, http.StatusTooManyRequests, loginPageData{Next: next, Error: "Слишком много попыток входа. Повторите позже."})
return
}
user := r.PostForm.Get("username")
if !a.checkCredentials(user, r.PostForm.Get("password")) {
a.throttle.fail(ip, now)
s.Log.Warn("login failed", "remote", ip)
s.renderLogin(w, http.StatusOK, loginPageData{Next: next, Error: "Неверный логин или пароль"})
return
}
a.throttle.clear(ip)
a.setCookie(w, r, user)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
clearCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// withAuthInfo returns a copy of the page-data struct data with its embedded
// PageData's AuthEnabled/User filled in, so the sidebar can show the logout
// control. Data without an embedded PageData is returned unchanged.
func (s *Server) withAuthInfo(r *http.Request, data interface{}) interface{} {
if !s.auth.enabled || data == nil {
return data
}
v := reflect.ValueOf(data)
if v.Kind() != reflect.Struct {
return data
}
cp := reflect.New(v.Type()).Elem()
cp.Set(v)
pd := cp.FieldByName("PageData")
if !pd.IsValid() || pd.Type() != reflect.TypeOf(PageData{}) {
return data
}
pd.FieldByName("AuthEnabled").SetBool(true)
pd.FieldByName("User").SetString(userFromRequest(r))
return cp.Interface()
}
+422
View File
@@ -0,0 +1,422 @@
package dashboard
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)
const (
testUser = "admin"
testPass = "correct horse battery staple"
testSecret = "test-session-secret"
)
func newAuthTestServer(t *testing.T, caURL string, mutate func(*Config)) (*Server, *httptest.Server) {
t.Helper()
cfg := Config{
ControlAPIBaseURL: caURL,
ControlAPITimeout: 5 * time.Second,
LastCompletedCount: 20,
OverviewPollIntervalS: 5,
ControlAPIToken: "ca-admin-token",
Username: testUser,
Password: testPass,
SessionSecret: testSecret,
SessionTTL: time.Hour,
}
if mutate != nil {
mutate(&cfg)
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, log)
if err != nil {
t.Fatalf("new dashboard server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return srv, ts
}
// noFollow is a client that returns redirects as-is.
func noFollow(ts *httptest.Server) *http.Client {
c := *ts.Client()
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &c
}
type reqOpts struct {
method string
path string
form url.Values
cookie string
headers map[string]string
}
func doReq(t *testing.T, ts *httptest.Server, o reqOpts) (*http.Response, string) {
t.Helper()
if o.method == "" {
o.method = http.MethodGet
}
var body io.Reader
if o.form != nil {
body = strings.NewReader(o.form.Encode())
}
req, err := http.NewRequest(o.method, ts.URL+o.path, body)
if err != nil {
t.Fatalf("new request: %v", err)
}
if o.form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if o.cookie != "" {
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: o.cookie})
}
for k, v := range o.headers {
req.Header.Set(k, v)
}
resp, err := noFollow(ts).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", o.method, o.path, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, string(b)
}
func sessionCookie(resp *http.Response) *http.Cookie {
for _, c := range resp.Cookies() {
if c.Name == sessionCookieName {
return c
}
}
return nil
}
func loginForm(user, pass string) url.Values {
return url.Values{"username": {user}, "password": {pass}}
}
// login performs a successful login and returns the session cookie value.
func login(t *testing.T, ts *httptest.Server) string {
t.Helper()
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL}})
c := sessionCookie(resp)
if resp.StatusCode != http.StatusSeeOther || c == nil {
t.Fatalf("login: status=%d cookie=%v, want 303 + session cookie", resp.StatusCode, c)
}
return c.Value
}
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{path: "/ips"})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status=%d, want 303", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/login?next=%2Fips" {
t.Fatalf("Location=%q, want /login?next=%%2Fips", loc)
}
}
func TestUnauthenticatedHTMXGets401WithHXRedirect(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{path: "/overview/fragment", headers: map[string]string{"HX-Request": "true"}})
if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("HX-Redirect") != "/login" {
t.Fatalf("status=%d HX-Redirect=%q, want 401 + /login", resp.StatusCode, resp.Header.Get("HX-Redirect"))
}
}
func TestLoginSuccessSetsCookieAndGrantsAccess(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"/ips"}},
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/ips" {
t.Fatalf("status=%d Location=%q, want 303 /ips", resp.StatusCode, resp.Header.Get("Location"))
}
c := sessionCookie(resp)
if c == nil {
t.Fatal("no session cookie")
}
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/" || c.MaxAge <= 0 {
t.Fatalf("cookie flags: httponly=%v samesite=%v path=%q maxage=%d", c.HttpOnly, c.SameSite, c.Path, c.MaxAge)
}
if c.Secure {
t.Fatal("cookie must not be Secure over plain HTTP")
}
resp, body := doReq(t, ts, reqOpts{path: "/overview", cookie: c.Value})
if resp.StatusCode != http.StatusOK {
t.Fatalf("authenticated GET /overview: status=%d", resp.StatusCode)
}
if !strings.Contains(body, "Выйти") || !strings.Contains(body, testUser) {
t.Fatal("sidebar must show the user and the logout button when auth is enabled")
}
}
func TestCookieSecureBehindHTTPSProxy(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL, "X-Forwarded-Proto": "https"}})
if c := sessionCookie(resp); c == nil || !c.Secure {
t.Fatalf("cookie=%v, want Secure with X-Forwarded-Proto=https", c)
}
}
func TestLoginWrongPasswordShowsErrorWithoutCookie(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
for _, f := range []url.Values{loginForm(testUser, "nope"), loginForm("nobody", testPass)} {
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: f,
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK || sessionCookie(resp) != nil {
t.Fatalf("status=%d cookie=%v, want 200 and no cookie", resp.StatusCode, sessionCookie(resp))
}
if !strings.Contains(body, "Неверный логин или пароль") {
t.Fatal("login page must show the error")
}
}
}
func TestTamperedAndExpiredCookiesRejected(t *testing.T) {
_, caURL := newFakeControlAPI(t)
srv, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
payload, sig, _ := strings.Cut(good, ".")
for name, v := range map[string]string{
"tampered payload": "AAAA" + payload[4:] + "." + sig,
"tampered signature": payload + "." + sig[:len(sig)-2] + "AA",
"no signature": payload,
"garbage": "x.y",
"signed with new key": otherKeyCookie(t),
} {
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: v})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("%s: status=%d, want 303 to /login", name, resp.StatusCode)
}
}
// Expiry: advance the server's clock beyond the TTL.
srv.auth.now = func() time.Time { return time.Now().Add(2 * time.Hour) }
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("expired cookie: status=%d, want 303", resp.StatusCode)
}
}
func otherKeyCookie(t *testing.T) string {
t.Helper()
a := newAuthState(Config{Username: "u", Password: "p", SessionSecret: "another-secret"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
v, _ := a.issue(testUser)
return v
}
func TestLogoutClearsCookie(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/logout", cookie: good,
headers: map[string]string{"Origin": ts.URL}})
c := sessionCookie(resp)
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" || c == nil || c.MaxAge >= 0 || c.Value != "" {
t.Fatalf("logout: status=%d loc=%q cookie=%+v", resp.StatusCode, resp.Header.Get("Location"), c)
}
}
func TestCSRFOriginCheck(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
cases := map[string]map[string]string{
"foreign origin": {"Origin": "http://evil.example"},
"null origin": {"Origin": "null"},
"no origin/referer": {},
"foreign referer": {"Referer": "http://evil.example/page"},
}
for name, h := range cases {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s: status=%d, want 403", name, resp.StatusCode)
}
}
// Login itself is covered too.
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": "http://evil.example"}})
if resp.StatusCode != http.StatusForbidden || sessionCookie(resp) != nil {
t.Fatalf("cross-origin login: status=%d, want 403 and no cookie", resp.StatusCode)
}
// Same-origin Origin, and Referer fallback, pass.
for name, h := range map[string]map[string]string{
"origin": {"Origin": ts.URL},
"referer": {"Referer": ts.URL + "/ips"},
} {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusSeeOther {
t.Fatalf("same-origin %s: status=%d, want request to be served", name, resp.StatusCode)
}
}
}
func TestLoginThrottle(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
for i := 0; i < maxLoginFailures; i++ {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, "bad"),
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("attempt %d: status=%d, want 200", i+1, resp.StatusCode)
}
}
// Now locked out, even with the right password.
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" || sessionCookie(resp) != nil {
t.Fatalf("status=%d Retry-After=%q, want 429 with Retry-After and no cookie", resp.StatusCode, resp.Header.Get("Retry-After"))
}
}
func TestLoginThrottleClearedBySuccessAndExpires(t *testing.T) {
th := newLoginThrottle()
now := time.Now()
for i := 0; i < maxLoginFailures-1; i++ {
th.fail("1.2.3.4", now)
}
if b, _ := th.blocked("1.2.3.4", now); b {
t.Fatal("blocked before reaching the limit")
}
th.fail("1.2.3.4", now)
if b, _ := th.blocked("1.2.3.4", now); !b {
t.Fatal("not blocked at the limit")
}
if b, _ := th.blocked("1.2.3.4", now.Add(loginFailureWindow+time.Second)); b {
t.Fatal("still blocked after the window")
}
if len(th.failures) != 0 {
t.Fatal("expired entries must be pruned")
}
th.fail("5.6.7.8", now)
th.clear("5.6.7.8")
if len(th.failures) != 0 {
t.Fatal("clear must drop the entry")
}
}
func TestStaticAndLoginPageOpen(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
if resp, _ := doReq(t, ts, reqOpts{path: "/static/dashboard.css"}); resp.StatusCode != http.StatusOK {
t.Fatalf("/static/dashboard.css: status=%d, want 200", resp.StatusCode)
}
resp, body := doReq(t, ts, reqOpts{path: "/login"})
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `name="password"`) {
t.Fatalf("/login: status=%d", resp.StatusCode)
}
}
func TestLoginNextOpenRedirectRejected(t *testing.T) {
for next, want := range map[string]string{
"/ips": "/ips",
"/ips?q=1": "/ips?q=1",
"//evil.example": "/",
"/\\evil.example": "/",
"http://evil.example": "/",
"https://evil.example/x": "/",
"javascript:alert(1)": "/",
"": "/",
"/a\r\nSet-Cookie: x": "/",
} {
if got := safeNext(next); got != want {
t.Fatalf("safeNext(%q)=%q, want %q", next, got, want)
}
}
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"//evil.example"}},
headers: map[string]string{"Origin": ts.URL}})
if resp.Header.Get("Location") != "/" {
t.Fatalf("Location=%q, want /", resp.Header.Get("Location"))
}
}
func TestControlAPITokenForwarded(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
t.Fatalf("GET /overview: status=%d", resp.StatusCode)
}
fake.authMu.Lock()
defer fake.authMu.Unlock()
if len(fake.authHeaders) == 0 {
t.Fatal("control-api was never called")
}
for _, h := range fake.authHeaders {
if h != "Bearer ca-admin-token" {
t.Fatalf("control-api Authorization=%q, want Bearer ca-admin-token", h)
}
}
}
func TestAuthDisabledWithZeroConfig(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL) // zero auth fields
resp, body := doReq(t, ts, reqOpts{path: "/overview"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 without login", resp.StatusCode)
}
if strings.Contains(body, "/logout") {
t.Fatal("logout control must be hidden when auth is disabled")
}
if resp, _ := doReq(t, ts, reqOpts{path: "/login"}); resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" {
t.Fatalf("/login with auth disabled: status=%d loc=%q, want redirect to /", resp.StatusCode, resp.Header.Get("Location"))
}
fake.authMu.Lock()
defer fake.authMu.Unlock()
for _, h := range fake.authHeaders {
if h != "" {
t.Fatalf("no token configured but Authorization=%q was sent", h)
}
}
}
func TestAuthDisabledWhenOnlyUsernameSet(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.Password = "" })
if resp, _ := doReq(t, ts, reqOpts{path: "/overview"}); resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 (auth disabled without password)", resp.StatusCode)
}
}
func TestEmptySessionSecretUsesRandomKey(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.SessionSecret = "" })
good := login(t, ts)
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 with random-key session", resp.StatusCode)
}
}
+160 -20
View File
@@ -8,6 +8,8 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -39,13 +41,44 @@ func (e *apiErr) Error() string {
type client struct {
baseURL string
http *http.Client
// long is used for clear / bulk operations, which legitimately take far
// longer than a plain read (thousands of rows in one transaction): same
// transport, but a longer whole-call timeout.
long *http.Client
// token, when non-empty, is sent to control-api as a Bearer credential.
token string
}
// longCallTimeout is the minimum whole-call timeout for clear and bulk
// operations (ClearQueue, DeleteIPs, SubmitIPs).
const longCallTimeout = 120 * time.Second
func newClient(baseURL string, timeout time.Duration) *client {
return &client{baseURL: baseURL, http: &http.Client{Timeout: timeout}}
longT := longCallTimeout
if timeout > longT {
longT = timeout
}
return &client{
baseURL: baseURL,
http: &http.Client{Timeout: timeout},
long: &http.Client{Timeout: longT},
}
}
func (c *client) do(ctx context.Context, method, path string, body, out interface{}) error {
return c.doWith(ctx, c.http, method, path, body, out)
}
// doLong is do with the long (clear/bulk) timeout.
func (c *client) doLong(ctx context.Context, method, path string, body, out interface{}) error {
hc := c.long
if hc == nil {
hc = c.http
}
return c.doWith(ctx, hc, method, path, body, out)
}
func (c *client) doWith(ctx context.Context, hc *http.Client, method, path string, body, out interface{}) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
@@ -61,8 +94,11 @@ func (c *client) do(ctx context.Context, method, path string, body, out interfac
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
resp, err := hc.Do(req)
if err != nil {
return &apiErr{Status: 0, Message: err.Error()}
}
@@ -91,9 +127,58 @@ func (c *client) Status(ctx context.Context) (statusResponse, error) {
return out, err
}
func (c *client) ListIPs(ctx context.Context) ([]ipQueueItem, error) {
var out []ipQueueItem
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips", nil, &out)
// maxPageLimit is control-api's cap on `limit`.
const maxPageLimit = 1000
// clampLimit keeps limit within 1..maxPageLimit: a request without `limit`
// would make control-api answer with the legacy unbounded bare array.
func clampLimit(limit int) int {
if limit < 1 {
return 1
}
if limit > maxPageLimit {
return maxPageLimit
}
return limit
}
// ipsQuery selects one page of GET /admin/ips: server-side filters plus
// limit/offset. Order is "sequence" (default) or "aggregated_at_desc".
type ipsQuery struct {
States []string
Q string
Result string
Order string
Limit int
Offset int
}
func (q ipsQuery) values() url.Values {
v := url.Values{}
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
if q.Offset > 0 {
v.Set("offset", strconv.Itoa(q.Offset))
}
if len(q.States) > 0 {
v.Set("state", strings.Join(q.States, ","))
}
if q.Q != "" {
v.Set("q", q.Q)
}
if q.Result != "" {
v.Set("result", q.Result)
}
if q.Order != "" {
v.Set("order", q.Order)
}
return v
}
// ListIPsPage returns one page of the check queue plus the total number of
// rows matching the filter. Never loads the whole queue.
func (c *client) ListIPsPage(ctx context.Context, q ipsQuery) (ipsPage, error) {
var out ipsPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips?"+q.values().Encode(), nil, &out)
return out, err
}
@@ -107,7 +192,7 @@ func (c *client) GetIP(ctx context.Context, ip string) (ipDetailResponse, error)
// forcing a recheck of already-finished ones — see docs/API.md.
func (c *client) SubmitIPs(ctx context.Context, addresses []string) (submitIPsResponse, error) {
var out submitIPsResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips", map[string][]string{"addresses": addresses}, &out)
return out, err
}
@@ -124,7 +209,7 @@ func (c *client) DeleteIP(ctx context.Context, ip string) error {
// DeleteIPs permanently removes a specific list of addresses in one call.
func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsResponse, error) {
var out deleteIPsResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out)
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/delete", map[string][]string{"addresses": addresses}, &out)
return out, err
}
@@ -132,25 +217,55 @@ func (c *client) DeleteIPs(ctx context.Context, addresses []string) (deleteIPsRe
// including those actively being checked.
func (c *client) ClearQueue(ctx context.Context) (clearQueueResponse, error) {
var out clearQueueResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out)
err := c.doLong(ctx, http.MethodPost, "/api/v1/admin/ips/clear", nil, &out)
return out, err
}
// ScanFloatingIPs lists the OpenStack project's free (unassociated)
// floating IPs and submits them to the check queue — see
// orchestrator.ScanFloatingIPs.
func (c *client) ScanFloatingIPs(ctx context.Context) (scanIPsResponse, error) {
var out scanIPsResponse
err := c.do(ctx, http.MethodPost, "/api/v1/admin/ips/scan", nil, &out)
// StartScan starts control-api's background floating-IP scan (or joins the
// one already running) and returns immediately with its current status.
func (c *client) StartScan(ctx context.Context, dryRun bool) (scanStatusDTO, error) {
var out scanStatusDTO
path := "/api/v1/admin/ips/scan"
if dryRun {
path += "?dry_run=true"
}
err := c.do(ctx, http.MethodPost, path, nil, &out)
return out, err
}
// ListRegistry returns every address ever submitted to the check queue,
// each with a summary of its accumulated check history — survives an
// address being deleted from the queue and later re-added.
func (c *client) ListRegistry(ctx context.Context) ([]registryItem, error) {
var out []registryItem
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry", nil, &out)
// ScanStatus returns the progress of the background scan job.
func (c *client) ScanStatus(ctx context.Context) (scanStatusDTO, error) {
var out scanStatusDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/ips/scan", nil, &out)
return out, err
}
// registryQuery selects one page of GET /admin/registry.
type registryQuery struct {
Q string
LastResult string
Limit int
Offset int
}
// ListRegistryPage returns one page of the registry (every address ever
// submitted to the check queue, each with a summary of its accumulated check
// history — survives an address being deleted from the queue and later
// re-added) plus the total number of rows matching the filter.
func (c *client) ListRegistryPage(ctx context.Context, q registryQuery) (registryPage, error) {
v := url.Values{}
v.Set("limit", strconv.Itoa(clampLimit(q.Limit)))
if q.Offset > 0 {
v.Set("offset", strconv.Itoa(q.Offset))
}
if q.Q != "" {
v.Set("q", q.Q)
}
if q.LastResult != "" {
v.Set("last_result", q.LastResult)
}
var out registryPage
err := c.do(ctx, http.MethodGet, "/api/v1/admin/registry?"+v.Encode(), nil, &out)
return out, err
}
@@ -252,3 +367,28 @@ func (c *client) PutInboundChecks(ctx context.Context, ports []int, icmp bool) (
inboundChecksDTO{Ports: ports, ICMP: icmp}, &out)
return out, err
}
func (c *client) GetAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodGet, "/api/v1/admin/auto-cycle", nil, &out)
return out, err
}
func (c *client) PutAutoCycle(ctx context.Context, intervalSeconds, maxRunSeconds int) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPut, "/api/v1/admin/auto-cycle",
map[string]int{"interval_seconds": intervalSeconds, "max_run_seconds": maxRunSeconds}, &out)
return out, err
}
func (c *client) StartAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/start", nil, &out)
return out, err
}
func (c *client) StopAutoCycle(ctx context.Context) (autoCycleDTO, error) {
var out autoCycleDTO
err := c.do(ctx, http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil, &out)
return out, err
}
+276 -17
View File
@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"testing"
@@ -41,6 +43,36 @@ type fakeControlAPI struct {
scanFreeAddresses []string
registry map[string]registryItem
registryChecks map[string][]check
// Scan job state machine (see the scan handlers): POST starts a job that
// stays "running" for scanRunPolls GET polls (0 = finishes at once), then
// ends as done — or as error when scanFinalError is set. scan is the status
// served by GET; tests may also set it directly (with scanPollsLeft == 0 it
// stays as is). scanStartStatus != 0 makes POST fail with that HTTP status.
scan scanStatusDTO
scanRunPolls int
scanPollsLeft int
scanFinalError string
scanStartStatus int
// Requests seen on the list endpoints, for "never loads everything" checks:
// the raw query of every GET /ips (ipsQueries) and the number of GET
// /registry calls without `limit` (bare), plus the sizes of the DeleteIPs /
// SubmitIPs bulk calls.
ipsQueries []string
registryQueries []string
bareIPsCalls int
deleteChunks []int
submitChunks []int
// autoCycle is the state served by /api/v1/admin/auto-cycle*;
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
autoCycle autoCycleDTO
autoCycleDown bool
// authHeaders records the Authorization header of every request served.
authMu sync.Mutex
authHeaders []string
}
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
@@ -53,6 +85,7 @@ func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
checkTypes: map[string]checkTypeDTO{},
registry: map[string]registryItem{},
registryChecks: map[string][]check{},
autoCycle: autoCycleDTO{IntervalSeconds: 3600, Phase: "idle"},
}
ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close)
@@ -76,16 +109,72 @@ func (f *fakeControlAPI) handler() http.Handler {
f.mu.Lock()
defer f.mu.Unlock()
byState := map[string]int{}
results := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
for _, ip := range f.ips {
byState[ip.State]++
if ip.OverallResult != "" {
results[ip.OverallResult]++
}
}
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators)})
writeJSON(w, http.StatusOK, statusResponse{TotalIPs: len(f.ips), IPsByState: byState, TotalValidators: len(f.validators), ResultsByOverall: results})
})
mux.HandleFunc("GET /api/v1/admin/ips", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writeJSON(w, http.StatusOK, f.ips)
f.ipsQueries = append(f.ipsQueries, r.URL.RawQuery)
qv := r.URL.Query()
if qv.Get("limit") == "" {
// Legacy shape: the whole queue as a bare array.
f.bareIPsCalls++
writeJSON(w, http.StatusOK, f.ips)
return
}
limit, err := strconv.Atoi(qv.Get("limit"))
if err != nil || limit < 1 || limit > 1000 {
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
return
}
offset, _ := strconv.Atoi(qv.Get("offset"))
var states map[string]bool
if st := qv.Get("state"); st != "" {
states = map[string]bool{}
for _, x := range strings.Split(st, ",") {
states[x] = true
}
}
var matched []ipQueueItem
for _, ip := range f.ips {
if states != nil && !states[ip.State] {
continue
}
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), strings.ToLower(q)) {
continue
}
if res := qv.Get("result"); res != "" && ip.OverallResult != res {
continue
}
matched = append(matched, ip)
}
if qv.Get("order") == "aggregated_at_desc" {
sort.SliceStable(matched, func(i, j int) bool {
a, b := matched[i].AggregatedAt, matched[j].AggregatedAt
if a == nil || b == nil {
return a != nil && b == nil
}
return a.After(*b)
})
} else {
sort.SliceStable(matched, func(i, j int) bool { return matched[i].Sequence < matched[j].Sequence })
}
page := []ipQueueItem{}
if offset < len(matched) {
page = matched[offset:]
if len(page) > limit {
page = page[:limit]
}
}
writeJSON(w, http.StatusOK, ipsPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
})
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", func(w http.ResponseWriter, r *http.Request) {
@@ -112,6 +201,7 @@ func (f *fakeControlAPI) handler() http.Handler {
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
return
}
f.submitChunks = append(f.submitChunks, len(req.Addresses))
resp := submitIPsResponse{}
for _, addr := range req.Addresses {
idx := f.findIP(addr)
@@ -180,6 +270,7 @@ func (f *fakeControlAPI) handler() http.Handler {
writeAPIErr(w, http.StatusBadRequest, "addresses must not be empty")
return
}
f.deleteChunks = append(f.deleteChunks, len(req.Addresses))
resp := deleteIPsResponse{}
for _, addr := range req.Addresses {
idx := f.findIP(addr)
@@ -200,6 +291,7 @@ func (f *fakeControlAPI) handler() http.Handler {
for _, ip := range f.ips {
resp.Deleted = append(resp.Deleted, ip.IPAddress)
}
resp.Count = len(resp.Deleted)
f.ips = nil
writeJSON(w, http.StatusOK, resp)
})
@@ -236,28 +328,134 @@ func (f *fakeControlAPI) handler() http.Handler {
mux.HandleFunc("POST /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
resp := scanIPsResponse{ScannedFree: len(f.scanFreeAddresses)}
for _, addr := range f.scanFreeAddresses {
idx := f.findIP(addr)
if idx < 0 {
now := time.Now()
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", CreatedAt: now, UpdatedAt: now})
resp.Added = append(resp.Added, addr)
continue
}
resp.Reordered = append(resp.Reordered, addr)
if f.scanStartStatus != 0 {
writeAPIErr(w, f.scanStartStatus, "scan refused")
return
}
writeJSON(w, http.StatusOK, resp)
if !f.scan.Running {
now := time.Now()
f.scan = scanStatusDTO{State: "listing", Running: true, DryRun: r.URL.Query().Get("dry_run") == "true", StartedAt: &now}
f.scanPollsLeft = f.scanRunPolls
if f.scanPollsLeft == 0 {
f.finishScan()
}
}
writeJSON(w, http.StatusAccepted, f.scan)
})
mux.HandleFunc("GET /api/v1/admin/ips/scan", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.scan.Running && f.scanPollsLeft > 0 {
f.scanPollsLeft--
if f.scanPollsLeft == 0 {
f.finishScan()
} else {
f.scan.Pages++
}
}
writeJSON(w, http.StatusOK, f.scan)
})
mux.HandleFunc("GET /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("PUT /api/v1/admin/auto-cycle", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
var req struct {
IntervalSeconds *int `json:"interval_seconds"`
MaxRunSeconds *int `json:"max_run_seconds"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.IntervalSeconds != nil && *req.IntervalSeconds < 60 {
writeAPIErr(w, http.StatusBadRequest, "interval_seconds must be >= 60: validation failed")
return
}
if req.MaxRunSeconds != nil && *req.MaxRunSeconds < 0 {
writeAPIErr(w, http.StatusBadRequest, "max_run_seconds must be >= 0: validation failed")
return
}
if req.IntervalSeconds != nil {
f.autoCycle.IntervalSeconds = *req.IntervalSeconds
}
if req.MaxRunSeconds != nil {
f.autoCycle.MaxRunSeconds = *req.MaxRunSeconds
}
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("POST /api/v1/admin/auto-cycle/start", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
if !f.autoCycle.Enabled {
now := time.Now()
f.autoCycle.Enabled = true
f.autoCycle.Phase = "idle"
f.autoCycle.NextRunAt = &now
}
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("POST /api/v1/admin/auto-cycle/stop", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if f.autoCycleDown {
writeAPIErr(w, http.StatusInternalServerError, "auto-cycle unavailable")
return
}
f.autoCycle.Enabled = false
f.autoCycle.Phase = "idle"
f.autoCycle.NextRunAt = nil
f.autoCycle.LastOutcome = "stopped"
writeJSON(w, http.StatusOK, f.autoCycle)
})
mux.HandleFunc("GET /api/v1/admin/registry", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]registryItem, 0, len(f.registry))
qv := r.URL.Query()
f.registryQueries = append(f.registryQueries, r.URL.RawQuery)
matched := make([]registryItem, 0, len(f.registry))
for _, item := range f.registry {
out = append(out, item)
if q := qv.Get("q"); q != "" && !strings.Contains(strings.ToLower(item.IPAddress), strings.ToLower(q)) {
continue
}
if lr := qv.Get("last_result"); lr != "" && item.LastResult != lr {
continue
}
matched = append(matched, item)
}
writeJSON(w, http.StatusOK, out)
sort.Slice(matched, func(i, j int) bool { return matched[i].IPAddress < matched[j].IPAddress })
if qv.Get("limit") == "" {
writeJSON(w, http.StatusOK, matched)
return
}
limit, err := strconv.Atoi(qv.Get("limit"))
if err != nil || limit < 1 || limit > 1000 {
writeAPIErr(w, http.StatusBadRequest, "limit must be 1..1000")
return
}
offset, _ := strconv.Atoi(qv.Get("offset"))
page := []registryItem{}
if offset < len(matched) {
page = matched[offset:]
if len(page) > limit {
page = page[:limit]
}
}
writeJSON(w, http.StatusOK, registryPage{Items: page, Total: len(matched), Limit: limit, Offset: offset})
})
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
@@ -460,7 +658,57 @@ func (f *fakeControlAPI) handler() http.Handler {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
return mux
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.authMu.Lock()
f.authHeaders = append(f.authHeaders, r.Header.Get("Authorization"))
f.authMu.Unlock()
mux.ServeHTTP(w, r)
})
}
// finishScan ends the running scan job (caller holds f.mu): the discovered
// free addresses are queued unless it was a dry run, or the job fails with
// scanFinalError.
func (f *fakeControlAPI) finishScan() {
now := time.Now()
f.scan.Running = false
f.scan.FinishedAt = &now
f.scan.Discovered = len(f.scanFreeAddresses)
f.scan.Free = len(f.scanFreeAddresses)
if f.scanFinalError != "" {
f.scan.State = "error"
f.scan.Error = f.scanFinalError
return
}
f.scan.State = "done"
if f.scan.DryRun {
return
}
for _, addr := range f.scanFreeAddresses {
if f.findIP(addr) < 0 {
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: len(f.ips) + 1, CreatedAt: now, UpdatedAt: now})
f.scan.Added++
} else {
f.scan.Reordered++
}
}
}
// seedIPs appends n queued addresses 10.x.y.z (distinct, in sequence order)
// and returns them. Use it for tests that need pages' worth of rows.
func (f *fakeControlAPI) seedIPs(n int) []string {
f.mu.Lock()
defer f.mu.Unlock()
now := time.Now()
out := make([]string, 0, n)
base := len(f.ips)
for i := 0; i < n; i++ {
k := base + i + 1
addr := fmt.Sprintf("10.%d.%d.%d", k/65536, (k/256)%256, k%256)
f.ips = append(f.ips, ipQueueItem{IPAddress: addr, State: "queued", Sequence: k, CreatedAt: now, UpdatedAt: now})
out = append(out, addr)
}
return out
}
func (f *fakeControlAPI) findIP(addr string) int {
@@ -515,3 +763,14 @@ func postForm(t *testing.T, ts *httptest.Server, method, path string, form url.V
body, _ := io.ReadAll(resp.Body)
return string(body)
}
// newSlowAPI serves an empty JSON object for every request after delay.
func newSlowAPI(t *testing.T, delay time.Duration) string {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(delay)
writeJSON(w, http.StatusOK, map[string]interface{}{})
}))
t.Cleanup(ts.Close)
return ts.URL
}
+231 -7
View File
@@ -1,6 +1,10 @@
package dashboard
import "time"
import (
"fmt"
"strconv"
"time"
)
// Wire shapes for control-api's /api/v1/admin/* surface, defined locally
// rather than importing internal/httpapi's (unexported) DTOs or
@@ -17,6 +21,27 @@ type statusResponse struct {
TotalIPs int `json:"total_ips"`
IPsByState map[string]int `json:"ips_by_state"`
TotalValidators int `json:"total_validators"`
// ResultsByOverall counts finished addresses by overall result
// (pass/partial/fail/cancelled).
ResultsByOverall map[string]int `json:"results_by_overall"`
}
// Terminal queue states: the address needs no further processing. Shared by
// the overview progress indicator; "occupied" counts as terminal too (the
// check cycle never ran because the floating IP was already bound).
var terminalStates = []string{"done", "failed", "occupied"}
// activeStates are the states of an address that is being worked on right
// now (everything between "queued" and a terminal state).
var activeStates = []string{"assigning_fip", "awaiting_self_check", "checking", "aggregating"}
// sumStates adds up the counts of the given states in a status breakdown.
func sumStates(byState map[string]int, states []string) int {
n := 0
for _, s := range states {
n += byState[s]
}
return n
}
type ipQueueItem struct {
@@ -96,14 +121,135 @@ type deleteIPsResponse struct {
type clearQueueResponse struct {
Deleted []string `json:"deleted"`
Count int `json:"count"`
}
type scanIPsResponse struct {
ScannedFree int `json:"scanned_free"`
Added []string `json:"added"`
Requeued []string `json:"requeued"`
Reordered []string `json:"reordered"`
SkippedInProgress []string `json:"skipped_in_progress"`
// ipsPage is the paginated envelope of GET /admin/ips (sent when the request
// carries `limit`).
type ipsPage struct {
Items []ipQueueItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// registryPage is the paginated envelope of GET /admin/registry.
type registryPage struct {
Items []registryItem `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// scanStatusDTO is the state of control-api's background floating-IP scan job
// (POST/GET /api/v1/admin/ips/scan).
type scanStatusDTO struct {
State string `json:"state"`
Running bool `json:"running"`
DryRun bool `json:"dry_run"`
Pages int `json:"pages"`
Discovered int `json:"discovered"`
Free int `json:"free"`
Added int `json:"added"`
Requeued int `json:"requeued"`
Reordered int `json:"reordered"`
SkippedInProgress int `json:"skipped_in_progress"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
Error string `json:"error"`
}
// Finished reports a job that has run to a terminal state (as opposed to
// "idle" = never started, or still running).
func (s scanStatusDTO) Finished() bool {
if s.Running {
return false
}
switch s.State {
case "done", "error", "cancelled":
return true
}
return false
}
// StateLabel is the Russian description of the job's state.
func (s scanStatusDTO) StateLabel() string {
switch s.State {
case "clearing":
return "очистка"
case "listing":
return "читаются страницы"
case "enqueuing":
return "ставятся в очередь"
case "done":
return "готово"
case "error":
return "ошибка"
case "cancelled":
return "отменено"
case "idle", "":
return "нет активного сканирования"
default:
return s.State
}
}
// PillClass picks the pill style for the state.
func (s scanStatusDTO) PillClass() string {
switch s.State {
case "done":
return "pill-success"
case "error":
return "pill-danger"
case "cancelled":
return "pill-cancel"
case "clearing", "listing", "enqueuing":
return "pill-info"
default:
return "pill-neutral"
}
}
// Handled is how many of the free addresses the enqueuing phase has already
// processed.
func (s scanStatusDTO) Handled() int {
return s.Added + s.Requeued + s.Reordered + s.SkippedInProgress
}
// Indeterminate is true while the amount of work is not known yet.
func (s scanStatusDTO) Indeterminate() bool {
return s.State == "clearing" || s.State == "listing" || (s.State == "enqueuing" && s.Free <= 0)
}
// Elapsed is the human-readable run time: until now while running, until
// finished_at afterwards; empty when the job never started.
func (s scanStatusDTO) Elapsed() string {
if s.StartedAt == nil {
return ""
}
end := time.Now()
if !s.Running && s.FinishedAt != nil {
end = *s.FinishedAt
}
return fmtDuration(end.Sub(*s.StartedAt))
}
// fmtDuration renders a duration in Russian as "2 ч 05 мин", "3 мин 07 с" or
// "42 с" — coarse on purpose (progress/ETA display).
func fmtDuration(d time.Duration) string {
if d < 0 {
d = 0
}
sec := int(d.Round(time.Second) / time.Second)
h, m, sc := sec/3600, (sec%3600)/60, sec%60
switch {
case h > 0:
return fmt.Sprintf("%d ч %02d мин", h, m)
case m > 0:
return fmt.Sprintf("%d мин %02d с", m, sc)
default:
return fmt.Sprintf("%d с", sc)
}
}
// registryItem is one row of the durable per-address registry — see
@@ -165,3 +311,81 @@ type inboundChecksDTO struct {
Ports []int `json:"ports"`
ICMP bool `json:"icmp"`
}
// autoCycleDTO mirrors internal/httpapi's autoCycleDTO — the status and
// parameters of the automatic check cycle (/api/v1/admin/auto-cycle).
type autoCycleDTO struct {
Enabled bool `json:"enabled"`
IntervalSeconds int `json:"interval_seconds"`
MaxRunSeconds int `json:"max_run_seconds"`
Phase string `json:"phase"`
RunStartedAt *time.Time `json:"run_started_at"`
NextRunAt *time.Time `json:"next_run_at"`
LastRunStartedAt *time.Time `json:"last_run_started_at"`
LastRunFinishedAt *time.Time `json:"last_run_finished_at"`
LastOutcome string `json:"last_outcome"`
LastError string `json:"last_error"`
LastScannedFree int `json:"last_scanned_free"`
RunsTotal int `json:"runs_total"`
}
// secondsToMinutes renders seconds as minutes for the settings form: a
// whole number when divisible by 60, otherwise a decimal ("1.5").
func secondsToMinutes(sec int) string {
return strconv.FormatFloat(float64(sec)/60, 'f', -1, 64)
}
// IntervalMinutes and MaxRunMinutes are used by the settings template: the
// UI works in minutes, the API in seconds.
func (a autoCycleDTO) IntervalMinutes() string { return secondsToMinutes(a.IntervalSeconds) }
func (a autoCycleDTO) MaxRunMinutes() string { return secondsToMinutes(a.MaxRunSeconds) }
// PhaseLabel is the Russian description of the current phase.
func (a autoCycleDTO) PhaseLabel() string {
switch a.Phase {
case "scanning":
return "сканирование Floating IP"
case "running":
return "идёт проверка"
case "waiting":
return "пауза между циклами"
case "idle":
return "ожидает запуска"
default:
return a.Phase
}
}
// OutcomeLabel is the Russian description of the last cycle's outcome.
func (a autoCycleDTO) OutcomeLabel() string {
switch a.LastOutcome {
case "completed":
return "завершён"
case "no_free_ips":
return "нет свободных IP"
case "timeout":
return "превышено время ожидания"
case "error":
return "ошибка"
case "stopped":
return "остановлен"
case "":
return "—"
default:
return a.LastOutcome
}
}
// OutcomePillClass picks the pill style for the last outcome.
func (a autoCycleDTO) OutcomePillClass() string {
switch a.LastOutcome {
case "completed":
return "pill-success"
case "no_free_ips", "timeout", "stopped":
return "pill-warning"
case "error":
return "pill-danger"
default:
return "pill-neutral"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ func (s *Server) handleCheckTypesPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadCheckTypesPage(r)
data.ActiveNav = "check-types"
data.Banner = bannerFor(err)
s.renderPage(w, "checktypes_page", data)
s.renderPage(w, r, "checktypes_page", data)
}
func (s *Server) renderCheckTypesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+281 -40
View File
@@ -1,14 +1,122 @@
package dashboard
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)
// bulkChunk is how many addresses go into one DeleteIPs/SubmitIPs call when
// an operation spans a whole filter ("scope=all").
const bulkChunk = 500
// ipStates are the queue states control-api accepts in the `state` filter.
var ipStates = []string{"queued", "assigning_fip", "awaiting_self_check", "checking", "aggregating", "done", "failed", "occupied"}
var ipResults = []string{"pass", "partial", "fail", "cancelled"}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// ipsFilter is the server-side filter of the /ips list. State is a filter
// token: one of "", "queued", "active" (every in-progress state), "done",
// "failed", "occupied", or a comma-separated list of raw queue states; the
// result filter is separate (Result) and may be combined with it.
type ipsFilter struct {
Q string
State string
Result string
}
// parseIPsFilter reads q/state/result from the request (query or form body).
// The state <select> posts a single `state` value, so "result:pass" selects a
// result filter; anything unrecognised is dropped rather than forwarded.
func parseIPsFilter(r *http.Request) ipsFilter {
f := ipsFilter{
Q: strings.TrimSpace(r.FormValue("q")),
State: strings.TrimSpace(r.FormValue("state")),
Result: strings.TrimSpace(r.FormValue("result")),
}
if res, ok := strings.CutPrefix(f.State, "result:"); ok {
f.State, f.Result = "", res
}
if !containsStr(ipResults, f.Result) {
f.Result = ""
}
switch f.State {
case "", "active":
default:
for _, st := range strings.Split(f.State, ",") {
if !containsStr(ipStates, st) {
f.State = ""
break
}
}
}
return f
}
// States expands the state token into the list sent to control-api.
func (f ipsFilter) States() []string {
switch f.State {
case "":
return nil
case "active":
return activeStates
default:
return strings.Split(f.State, ",")
}
}
// Active reports whether any filter is applied.
func (f ipsFilter) Active() bool { return f.Q != "" || f.State != "" || f.Result != "" }
// Token is the value of the state <select> option matching the filter.
func (f ipsFilter) Token() string {
if f.State == "" && f.Result != "" {
return "result:" + f.Result
}
return f.State
}
func (f ipsFilter) values() url.Values {
v := url.Values{}
if f.Q != "" {
v.Set("q", f.Q)
}
if f.State != "" {
v.Set("state", f.State)
}
if f.Result != "" {
v.Set("result", f.Result)
}
return v
}
type ipsPageData struct {
PageData
Items []ipQueueItem
FIPSettleSeconds int
Filter ipsFilter
Page, PerPage int
// Total is the number of rows matching the filter; QueueTotal is the whole
// queue (what "Очистить всё" would remove).
Total, QueueTotal int
Pager pagerData
// SelfURL is this page's own URL (filter + page), re-requested to reload
// the table when a scan finishes.
SelfURL string
PerPageOptions []int
Scan scanProgressData
}
type ipDetailData struct {
@@ -16,16 +124,70 @@ type ipDetailData struct {
Detail ipDetailResponse
}
func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListIPs(r.Context())
settings, settingsErr := s.CA.GetOrchestratorSettings(r.Context())
// loadIPsData fetches the page of the queue selected by the request's
// page/per_page/q/state/result params. A page that became empty (e.g. after
// deleting its last rows) is clamped to the last non-empty page.
func (s *Server) loadIPsData(r *http.Request) (ipsPageData, error) {
ctx := r.Context()
f := parseIPsFilter(r)
perPage := parsePerPage(r.FormValue("per_page"))
page := parsePage(r.FormValue("page"))
q := ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: perPage, Offset: (page - 1) * perPage}
res, err := s.CA.ListIPsPage(ctx, q)
if err == nil {
err = settingsErr
if clamped := clampPage(page, res.Total, perPage); clamped != page {
page = clamped
q.Offset = (page - 1) * perPage
res, err = s.CA.ListIPsPage(ctx, q)
}
}
data := ipsPageData{
Items: res.Items,
Filter: f,
Page: page,
PerPage: perPage,
Total: res.Total,
QueueTotal: res.Total,
PerPageOptions: perPageOptions,
}
data.Pager = newPager("/ips", "ips-table-wrap", f.values(), page, perPage, res.Total)
data.SelfURL = pageURL("/ips", f.values(), page, perPage)
if settings, settingsErr := s.CA.GetOrchestratorSettings(ctx); settingsErr != nil {
if err == nil {
err = settingsErr
}
} else {
data.FIPSettleSeconds = settings.FIPSettleSeconds
}
if err == nil && f.Active() {
// "Очистить всё" ignores the filter: show the real queue size in its
// confirmation. Non-fatal — the label just falls back to the filtered total.
if st, stErr := s.CA.Status(ctx); stErr == nil {
data.QueueTotal = st.TotalIPs
}
}
return data, err
}
func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadIPsData(r)
// A filter/pager request from htmx swaps only #ips-table-wrap (hx-select),
// so there is no need to re-render the whole page (and re-query the scan
// status). A history-restore fetch needs the full page.
if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-History-Restore-Request") != "true" {
s.renderFragment(w, "ips_table_wrap", data, err)
return
}
if st, scanErr := s.CA.ScanStatus(r.Context()); scanErr != nil {
s.Log.Warn("ips: scan status unavailable", "err", scanErr)
} else {
data.Scan = newScanProgress(st)
}
data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}
data.ActiveNav = "ips"
data.Banner = bannerFor(err)
s.renderPage(w, "ips_page", data)
s.renderPage(w, r, "ips_page", data)
}
func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
@@ -34,23 +196,21 @@ func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
data := ipDetailData{Detail: detail}
data.ActiveNav = "ips"
data.Banner = bannerFor(err)
s.renderPage(w, "ip_detail_page", data)
s.renderPage(w, r, "ip_detail_page", data)
}
// renderIPsTable re-fetches the current queue and renders the ips_table
// fragment, tagging actionErr (if any) on the shared error banner. Called
// after every mutating /ips/* request so the table always reflects true
// current state regardless of whether the mutation itself succeeded.
// renderIPsTable re-fetches the current page of the queue (same page/filter as
// the request, carried in hidden #ips-form inputs) and renders the
// ips_table_wrap fragment, tagging actionErr (if any) on the shared error
// banner. Called after every mutating /ips/* request so the table always
// reflects true current state regardless of whether the mutation itself
// succeeded.
func (s *Server) renderIPsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
items, listErr := s.CA.ListIPs(r.Context())
data, err := s.loadIPsData(r)
if actionErr == nil {
actionErr = listErr
actionErr = err
}
settings, settingsErr := s.CA.GetOrchestratorSettings(r.Context())
if actionErr == nil {
actionErr = settingsErr
}
s.renderFragment(w, "ips_table", ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}, actionErr)
s.renderFragment(w, "ips_table_wrap", data, actionErr)
}
func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
@@ -63,7 +223,7 @@ func (s *Server) handleIPsSubmit(w http.ResponseWriter, r *http.Request) {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "укажите хотя бы один адрес"})
return
}
_, err := s.CA.SubmitIPs(r.Context(), addresses)
err := s.submitChunked(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
@@ -85,31 +245,76 @@ func (s *Server) handleIPDelete(w http.ResponseWriter, r *http.Request) {
s.renderIPsTable(w, r, err)
}
func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) {
// submitChunked feeds addresses to SubmitIPs in chunks of bulkChunk so one
// huge list never becomes one huge request/transaction.
func (s *Server) submitChunked(ctx context.Context, addresses []string) error {
for _, part := range chunk(addresses, bulkChunk) {
if _, err := s.CA.SubmitIPs(ctx, part); err != nil {
return err
}
}
return nil
}
// resolveFilterAddresses lists every address matching filter by paging
// ListIPsPage (≤ maxPageLimit rows per call), for "select all N by filter".
func (s *Server) resolveFilterAddresses(ctx context.Context, f ipsFilter) ([]string, error) {
var out []string
for offset := 0; ; {
page, err := s.CA.ListIPsPage(ctx, ipsQuery{States: f.States(), Q: f.Q, Result: f.Result, Order: "sequence", Limit: maxPageLimit, Offset: offset})
if err != nil {
return nil, err
}
for _, it := range page.Items {
out = append(out, it.IPAddress)
}
offset += len(page.Items)
if len(page.Items) == 0 || offset >= page.Total {
return out, nil
}
}
}
// bulkAddresses returns the addresses a bulk delete/recheck acts on: the
// checked rows of the current page, or — with scope=all — everything that
// matches the current filter, resolved server-side.
func (s *Server) bulkAddresses(r *http.Request) ([]string, error) {
if err := r.ParseForm(); err != nil {
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
return nil, fmt.Errorf("invalid form: %w", err)
}
var addresses []string
if r.FormValue("scope") == "all" {
var err error
addresses, err = s.resolveFilterAddresses(r.Context(), parseIPsFilter(r))
if err != nil {
return nil, err
}
} else {
addresses = r.Form["addresses"]
}
addresses := r.Form["addresses"]
if len(addresses) == 0 {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"})
return
return nil, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"}
}
return addresses, nil
}
func (s *Server) handleIPsDeleteSelected(w http.ResponseWriter, r *http.Request) {
addresses, err := s.bulkAddresses(r)
if err == nil {
for _, part := range chunk(addresses, bulkChunk) {
if _, err = s.CA.DeleteIPs(r.Context(), part); err != nil {
break
}
}
}
_, err := s.CA.DeleteIPs(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
func (s *Server) handleIPsRecheckSelected(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderIPsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
addresses, err := s.bulkAddresses(r)
if err == nil {
err = s.submitChunked(r.Context(), addresses)
}
addresses := r.Form["addresses"]
if len(addresses) == 0 {
s.renderIPsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "ничего не выбрано"})
return
}
_, err := s.CA.SubmitIPs(r.Context(), addresses)
s.renderIPsTable(w, r, err)
}
@@ -118,10 +323,46 @@ func (s *Server) handleIPsClear(w http.ResponseWriter, r *http.Request) {
s.renderIPsTable(w, r, err)
}
// handleIPsScan lists the OpenStack project's free (unassociated) floating
// IPs and submits them to the check queue in one step — see
// client.ScanFloatingIPs.
// scanProgressData drives the scan_progress partial. Poll keeps the partial's
// own hx-trigger="every 2s" alive: while the job runs, or while control-api is
// transiently unreachable (so one failed poll doesn't freeze the panel).
type scanProgressData struct {
Status scanStatusDTO
Poll bool
}
func newScanProgress(st scanStatusDTO) scanProgressData {
return scanProgressData{Status: st, Poll: st.Running}
}
// renderScanProgress renders the scan panel fragment. A finished job also
// sends `HX-Trigger: scan-finished`, which makes #ips-table-wrap reload.
// Errors go to the shared banner; only transient ones (transport/5xx) keep
// polling when keepPolling is set.
func (s *Server) renderScanProgress(w http.ResponseWriter, st scanStatusDTO, err error, keepPolling bool) {
data := newScanProgress(st)
if err != nil {
data = scanProgressData{}
var ae *apiErr
if keepPolling && errors.As(err, &ae) && (ae.Status == 0 || ae.Status >= 500) {
data.Poll = true
}
} else if st.Finished() {
w.Header().Set("HX-Trigger", "scan-finished")
}
s.renderFragment(w, "scan_progress", data, err)
}
// handleIPsScan starts the background floating-IP scan (or joins the running
// one) and returns the progress panel at once — the job itself runs in
// control-api, so this never waits for OpenStack. ?dry_run=true only counts.
func (s *Server) handleIPsScan(w http.ResponseWriter, r *http.Request) {
_, err := s.CA.ScanFloatingIPs(r.Context())
s.renderIPsTable(w, r, err)
st, err := s.CA.StartScan(r.Context(), r.URL.Query().Get("dry_run") == "true")
s.renderScanProgress(w, st, err, false)
}
// handleIPsScanStatus is the progress panel's poll target.
func (s *Server) handleIPsScanStatus(w http.ResponseWriter, r *http.Request) {
st, err := s.CA.ScanStatus(r.Context())
s.renderScanProgress(w, st, err, true)
}
+116 -68
View File
@@ -2,20 +2,51 @@ package dashboard
import (
"net/http"
"sort"
"strings"
"time"
)
const (
// overviewActiveLimit caps the "В работе" table; overviewQueueLimit caps
// the compact list of the next queued addresses. The queue itself can hold
// thousands of rows, so the overview never lists more than these.
overviewActiveLimit = 100
overviewQueueLimit = 10
// etaMinSamples is how many completed rows (with AggregatedAt) the ETA
// needs to derive a rate from.
etaMinSamples = 5
)
// overviewProgress is the "Готово D из T" indicator of the stats block.
type overviewProgress struct {
Total, Done, Active, Queued, Percent int
// ETA is a rough time-to-finish estimate ("" when not enough data).
ETA string
}
type overviewData struct {
PageData
Status statusResponse
CurrentItems []ipQueueItem
Status statusResponse
// ActiveItems are the addresses being checked right now (≤ overviewActiveLimit
// of ActiveTotal); QueuedItems are the next few waiting ones (QueuedTotal
// in total). Both stay empty under a result filter: an address without a
// verdict can't match one.
ActiveItems []ipQueueItem
ActiveTotal int
QueuedItems []ipQueueItem
QueuedTotal int
LastCompleted []ipQueueItem
Breakdown map[string]int
LastN int
PollSeconds int
Query string
StatusFilter string
Progress overviewProgress
// AutoCycle is nil when the auto-cycle status could not be fetched; the
// indicator is then simply hidden (a non-fatal failure). Scan is likewise
// nil when the scan status is unavailable.
AutoCycle *autoCycleDTO
Scan *scanStatusDTO
}
func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
@@ -24,30 +55,65 @@ func (s *Server) loadOverview(r *http.Request) (overviewData, error) {
if err != nil {
return overviewData{}, err
}
ips, err := s.CA.ListIPs(ctx)
q := strings.TrimSpace(r.URL.Query().Get("q"))
resultFilter := r.URL.Query().Get("status")
if !containsStr(ipResults, resultFilter) {
resultFilter = ""
}
lastN := s.Cfg.LastCompletedCount
if lastN < 1 {
lastN = 20
}
data := overviewData{
Status: status,
LastN: lastN,
PollSeconds: s.Cfg.OverviewPollIntervalS,
Query: q,
StatusFilter: resultFilter,
}
if resultFilter == "" {
active, err := s.CA.ListIPsPage(ctx, ipsQuery{States: activeStates, Q: q, Order: "sequence", Limit: overviewActiveLimit})
if err != nil {
return overviewData{}, err
}
queued, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"queued"}, Q: q, Order: "sequence", Limit: overviewQueueLimit})
if err != nil {
return overviewData{}, err
}
data.ActiveItems, data.ActiveTotal = active.Items, active.Total
data.QueuedItems, data.QueuedTotal = queued.Items, queued.Total
}
completed, err := s.CA.ListIPsPage(ctx, ipsQuery{States: []string{"done", "failed"}, Q: q, Result: resultFilter, Order: "aggregated_at_desc", Limit: lastN})
if err != nil {
return overviewData{}, err
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
resultFilter := r.URL.Query().Get("status")
last := lastCompleted(ips, s.Cfg.LastCompletedCount)
return overviewData{
Status: status,
CurrentItems: filterQueueItems(currentlyChecking(ips), q, resultFilter),
LastCompleted: filterQueueItems(last, q, resultFilter),
Breakdown: resultBreakdown(last),
LastN: s.Cfg.LastCompletedCount,
PollSeconds: s.Cfg.OverviewPollIntervalS,
Query: q,
StatusFilter: resultFilter,
}, nil
data.LastCompleted = completed.Items
data.Breakdown = resultBreakdown(completed.Items)
// ETA from the unfiltered window only: a filtered list is not a sample of
// the checks' real throughput.
data.Progress = computeProgress(status, completed.Items, q == "" && resultFilter == "")
if ac, acErr := s.CA.GetAutoCycle(ctx); acErr != nil {
s.Log.Warn("overview: auto-cycle status unavailable", "err", acErr)
} else {
data.AutoCycle = &ac
}
if sc, scErr := s.CA.ScanStatus(ctx); scErr != nil {
s.Log.Warn("overview: scan status unavailable", "err", scErr)
} else if sc.Running {
data.Scan = &sc
}
return data, nil
}
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
data, err := s.loadOverview(r)
data.ActiveNav = "overview"
data.Banner = bannerFor(err)
s.renderPage(w, "overview_page", data)
s.renderPage(w, r, "overview_page", data)
}
// handleOverviewFragment serves both the recurring poll and every
@@ -65,40 +131,45 @@ func (s *Server) handleOverviewFragment(w http.ResponseWriter, r *http.Request)
}
}
// currentlyChecking is every IP not yet in a terminal state, ordered by
// queue position — the "текущая проверка" live snapshot. No backend
// concept of a "run" exists; this is computed fresh on every request.
func currentlyChecking(ips []ipQueueItem) []ipQueueItem {
var out []ipQueueItem
for _, ip := range ips {
if ip.State != "done" && ip.State != "failed" {
out = append(out, ip)
// computeProgress derives the done/active/queued counters from the status
// breakdown (done = every terminal state, "occupied" included) and, when
// useETA is set and the newest-first list of completed rows has at least
// etaMinSamples with AggregatedAt, a rough ETA from their completion rate.
func computeProgress(st statusResponse, completed []ipQueueItem, useETA bool) overviewProgress {
p := overviewProgress{
Total: st.TotalIPs,
Done: sumStates(st.IPsByState, terminalStates),
Active: sumStates(st.IPsByState, activeStates),
Queued: st.IPsByState["queued"],
}
if p.Total > 0 {
p.Percent = p.Done * 100 / p.Total
}
remaining := p.Active + p.Queued
if !useETA || remaining == 0 {
return p
}
var stamps []time.Time
for _, ip := range completed {
if ip.AggregatedAt != nil {
stamps = append(stamps, *ip.AggregatedAt)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Sequence < out[j].Sequence })
return out
}
// lastCompleted returns the n most recently completed (done/failed) IPs by
// AggregatedAt descending — the "последняя завершённая проверка" summary
// window. This is an operational definition, not a real "batch": resubmit
// n if the window size needs tuning (overview.last_completed_count).
func lastCompleted(ips []ipQueueItem, n int) []ipQueueItem {
var done []ipQueueItem
for _, ip := range ips {
if (ip.State == "done" || ip.State == "failed") && ip.AggregatedAt != nil {
done = append(done, ip)
}
if len(stamps) < etaMinSamples {
return p
}
sort.Slice(done, func(i, j int) bool { return done[i].AggregatedAt.After(*done[j].AggregatedAt) })
if len(done) > n {
done = done[:n]
// completed is newest-first: stamps[0] is the newest, the last the oldest.
span := stamps[0].Sub(stamps[len(stamps)-1])
if span <= 0 {
return p
}
return done
perItem := span / time.Duration(len(stamps)-1)
p.ETA = fmtDuration(perItem * time.Duration(remaining))
return p
}
// resultBreakdown counts OverallResult values across exactly the given
// items (normally the output of lastCompleted) — pass/partial/fail/cancelled.
// items (the "последние N завершённых" window) — pass/partial/fail/cancelled.
func resultBreakdown(items []ipQueueItem) map[string]int {
out := map[string]int{"pass": 0, "partial": 0, "fail": 0, "cancelled": 0}
for _, ip := range items {
@@ -106,26 +177,3 @@ func resultBreakdown(items []ipQueueItem) map[string]int {
}
return out
}
// filterQueueItems narrows items to those whose address contains q
// (case-insensitive substring) and, if status is set, whose OverallResult
// matches it exactly. A still-in-progress item always has an empty
// OverallResult, so picking any specific status hides it — the intended
// behavior for "Текущая проверка", which has no verdict yet.
func filterQueueItems(items []ipQueueItem, q, status string) []ipQueueItem {
if q == "" && status == "" {
return items
}
q = strings.ToLower(q)
out := make([]ipQueueItem, 0, len(items))
for _, ip := range items {
if q != "" && !strings.Contains(strings.ToLower(ip.IPAddress), q) {
continue
}
if status != "" && ip.OverallResult != status {
continue
}
out = append(out, ip)
}
return out
}
+42 -32
View File
@@ -2,14 +2,19 @@ package dashboard
import (
"net/http"
"net/url"
"strings"
)
type registryPageData struct {
PageData
Items []registryItem
Query string
StatusFilter string
Items []registryItem
Query string
StatusFilter string
Page, PerPage int
Total int
Pager pagerData
PerPageOptions []int
}
type registryDetailData struct {
@@ -22,41 +27,46 @@ type registryDetailData struct {
// record that survives an address being deleted from /ips and later
// re-added. See internal/db/migrations/0007_ip_registry.sql. Optional
// ?q=&status= query params narrow the list by address substring and by
// LastResult — see filterRegistryItems.
// LastResult, and ?page=&per_page= select a page — all applied server-side
// (control-api's ListRegistryPage), so only the visible rows are transferred.
func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
items, err := s.CA.ListRegistry(r.Context())
q := strings.TrimSpace(r.URL.Query().Get("q"))
status := r.URL.Query().Get("status")
if !containsStr(ipResults, status) {
status = ""
}
perPage := parsePerPage(r.URL.Query().Get("per_page"))
page := parsePage(r.URL.Query().Get("page"))
query := registryQuery{Q: q, LastResult: status, Limit: perPage, Offset: (page - 1) * perPage}
res, err := s.CA.ListRegistryPage(r.Context(), query)
if err == nil {
if clamped := clampPage(page, res.Total, perPage); clamped != page {
page = clamped
query.Offset = (page - 1) * perPage
res, err = s.CA.ListRegistryPage(r.Context(), query)
}
}
params := url.Values{}
if q != "" {
params.Set("q", q)
}
if status != "" {
params.Set("status", status)
}
data := registryPageData{
Items: filterRegistryItems(items, q, status),
Query: q,
StatusFilter: status,
Items: res.Items,
Query: q,
StatusFilter: status,
Page: page,
PerPage: perPage,
Total: res.Total,
Pager: newPager("/registry", "registry-table-wrap", params, page, perPage, res.Total),
PerPageOptions: perPageOptions,
}
data.ActiveNav = "registry"
data.Banner = bannerFor(err)
s.renderPage(w, "registry_page", data)
}
// filterRegistryItems narrows items to those whose address contains q
// (case-insensitive substring) and, if status is set, whose LastResult
// matches it exactly — the registry list's search-by-IP and
// filter-by-status, mirroring filterQueueItems in handlers_overview.go.
func filterRegistryItems(items []registryItem, q, status string) []registryItem {
if q == "" && status == "" {
return items
}
q = strings.ToLower(q)
out := make([]registryItem, 0, len(items))
for _, it := range items {
if q != "" && !strings.Contains(strings.ToLower(it.IPAddress), q) {
continue
}
if status != "" && it.LastResult != status {
continue
}
out = append(out, it)
}
return out
s.renderPage(w, r, "registry_page", data)
}
// handleRegistryDetail shows one address's full retained check history
@@ -68,5 +78,5 @@ func (s *Server) handleRegistryDetail(w http.ResponseWriter, r *http.Request) {
data := registryDetailData{History: history}
data.ActiveNav = "registry"
data.Banner = bannerFor(err)
s.renderPage(w, "registry_detail_page", data)
s.renderPage(w, r, "registry_detail_page", data)
}
+56 -3
View File
@@ -2,6 +2,7 @@ package dashboard
import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
@@ -11,6 +12,8 @@ type settingsPageData struct {
PageData
Settings orchestratorSettingsDTO
Inbound inboundChecksDTO
// AutoCycle is the automatic-check-cycle panel's status/parameters.
AutoCycle autoCycleDTO
}
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
@@ -19,10 +22,14 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
if err == nil {
err = inboundErr
}
data := settingsPageData{Settings: settings, Inbound: inbound}
autoCycle, autoCycleErr := s.CA.GetAutoCycle(r.Context())
if err == nil {
err = autoCycleErr
}
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
data.ActiveNav = "settings"
data.Banner = bannerFor(err)
s.renderPage(w, "settings_page", data)
s.renderPage(w, r, "settings_page", data)
}
// renderSettingsForm re-fetches the current settings and renders the
@@ -38,7 +45,11 @@ func (s *Server) renderSettingsForm(w http.ResponseWriter, r *http.Request, acti
if actionErr == nil {
actionErr = inboundErr
}
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound}, actionErr)
autoCycle, autoCycleErr := s.CA.GetAutoCycle(r.Context())
if actionErr == nil {
actionErr = autoCycleErr
}
s.renderFragment(w, "settings_form", settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}, actionErr)
}
func (s *Server) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
@@ -90,3 +101,45 @@ func (s *Server) handleInboundChecksPut(w http.ResponseWriter, r *http.Request)
_, err := s.CA.PutInboundChecks(r.Context(), ports, icmp)
s.renderSettingsForm(w, r, err)
}
// parseMinutes converts a form field holding a (possibly fractional) number
// of minutes into whole seconds.
func parseMinutes(raw string) (int, error) {
f, err := strconv.ParseFloat(strings.TrimSpace(strings.ReplaceAll(raw, ",", ".")), 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || math.Abs(f) > 1e6 {
return 0, fmt.Errorf("not a number of minutes: %q", raw)
}
return int(math.Round(f * 60)), nil
}
// handleAutoCyclePut saves the auto-cycle interval and maximum run duration
// (entered in minutes, sent to control-api in seconds). It does not touch
// the enabled flag — that's what the start/stop buttons are for.
func (s *Server) handleAutoCyclePut(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderSettingsForm(w, r, fmt.Errorf("invalid form: %w", err))
return
}
intervalSec, err := parseMinutes(r.PostFormValue("interval_minutes"))
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "интервал должен быть числом минут"})
return
}
maxRunSec, err := parseMinutes(r.PostFormValue("max_run_minutes"))
if err != nil {
s.renderSettingsForm(w, r, &apiErr{Status: http.StatusBadRequest, Message: "максимальная длительность должна быть числом минут (0 — без лимита)"})
return
}
_, err = s.CA.PutAutoCycle(r.Context(), intervalSec, maxRunSec)
s.renderSettingsForm(w, r, err)
}
func (s *Server) handleAutoCycleStart(w http.ResponseWriter, r *http.Request) {
_, err := s.CA.StartAutoCycle(r.Context())
s.renderSettingsForm(w, r, err)
}
func (s *Server) handleAutoCycleStop(w http.ResponseWriter, r *http.Request) {
_, err := s.CA.StopAutoCycle(r.Context())
s.renderSettingsForm(w, r, err)
}
+1 -1
View File
@@ -16,7 +16,7 @@ func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) {
data := sitesPageData{Items: items}
data.ActiveNav = "sites"
data.Banner = bannerFor(err)
s.renderPage(w, "sites_page", data)
s.renderPage(w, r, "sites_page", data)
}
func (s *Server) renderSitesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+1 -1
View File
@@ -83,7 +83,7 @@ func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadTargetsPage(r)
data.ActiveNav = "targets"
data.Banner = bannerFor(err)
s.renderPage(w, "targets_page", data)
s.renderPage(w, r, "targets_page", data)
}
// renderTargetsTable renders the #targets-table-wrap swap target plus,
+169 -58
View File
@@ -558,20 +558,6 @@ func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
}
}
func TestIPsScan(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanFreeAddresses = []string{"5.5.5.5"}
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/ips/scan", nil)
if !strings.Contains(body, "5.5.5.5") {
t.Fatalf("expected scanned address in re-rendered table, got:\n%s", body)
}
if len(fake.ips) != 1 || fake.ips[0].IPAddress != "5.5.5.5" {
t.Fatalf("expected fake control-api queue to contain the scanned address, got %+v", fake.ips)
}
}
func TestRegistryPageAndDetail(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
@@ -667,60 +653,185 @@ func TestControlAPIUnreachable(t *testing.T) {
}
}
func TestFilterQueueItems(t *testing.T) {
items := []ipQueueItem{
{IPAddress: "1.1.1.1", OverallResult: "pass"},
{IPAddress: "1.1.1.2", OverallResult: "fail"},
{IPAddress: "2.2.2.2", OverallResult: "pass"},
func TestAutoCyclePanelRenders(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
finished := time.Now().Add(-time.Hour)
fake.autoCycle = autoCycleDTO{
IntervalSeconds: 5400, MaxRunSeconds: 1800, Phase: "waiting",
LastRunFinishedAt: &finished, LastOutcome: "timeout", LastError: "checks did not finish", RunsTotal: 4,
}
ts := newTestServer(t, caURL)
if got := filterQueueItems(items, "", ""); len(got) != 3 {
t.Fatalf("expected no-op with empty q/status, got %+v", got)
page := get(t, ts, "/settings")
for _, want := range []string{
"Автоматический цикл",
"Интервал между циклами (мин)",
"Максимальная длительность проверки (мин, 0 = без лимита)",
`name="interval_minutes" min="1" step="any" value="90"`,
`name="max_run_minutes" min="0" step="any" value="30"`,
"Автоцикл выключен",
"пауза между циклами",
"превышено время ожидания",
"checks did not finish",
`hx-put="/settings/auto-cycle"`,
`hx-post="/settings/auto-cycle/start"`,
"Включить",
"Сохранить",
} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q on the settings page, got:\n%s", want, page)
}
}
if got := filterQueueItems(items, "1.1.1", ""); len(got) != 2 {
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
t.Fatalf("expected exact-substring match, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1.1", ""); len(got) != 1 {
t.Fatalf("expected search to be case/substring based, got %+v", got)
}
if got := filterQueueItems(items, "", "pass"); len(got) != 2 {
t.Fatalf("expected 2 matches for status=pass, got %+v", got)
}
if got := filterQueueItems(items, "1.1.1", "pass"); len(got) != 1 || got[0].IPAddress != "1.1.1.1" {
t.Fatalf("expected q+status combined with AND, got %+v", got)
}
if got := filterQueueItems(items, "9.9.9.9", ""); len(got) != 0 {
t.Fatalf("expected no matches, got %+v", got)
}
// Case-insensitivity, via a query with mixed-case letters (IP octets
// are numeric, so exercise it through IPv6-shaped input instead).
mixed := []ipQueueItem{{IPAddress: "fe80::AbCd"}}
if got := filterQueueItems(mixed, "abcd", ""); len(got) != 1 {
t.Fatalf("expected case-insensitive search to match, got %+v", got)
if strings.Contains(page, "Выключить") {
t.Fatalf("stop button must be hidden while the auto-cycle is disabled, got:\n%s", page)
}
}
func TestFilterRegistryItems(t *testing.T) {
items := []registryItem{
{IPAddress: "1.1.1.1", LastResult: "pass"},
{IPAddress: "1.1.1.2", LastResult: "partial"},
{IPAddress: "2.2.2.2", LastResult: ""},
func TestAutoCycleStartAndStopButtons(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "POST", "/settings/auto-cycle/start", nil)
if !fake.autoCycle.Enabled {
t.Fatalf("expected the fake control-api auto-cycle enabled after start")
}
if !strings.Contains(body, "Автоцикл включён") || !strings.Contains(body, "Выключить") ||
!strings.Contains(body, `hx-post="/settings/auto-cycle/stop"`) {
t.Fatalf("expected the re-rendered panel to show the enabled state with a stop button, got:\n%s", body)
}
if strings.Contains(body, "alert-") {
t.Fatalf("expected no error banner after a successful start, got:\n%s", body)
}
if got := filterRegistryItems(items, "", ""); len(got) != 3 {
t.Fatalf("expected no-op with empty q/status, got %+v", got)
body = postForm(t, ts, "POST", "/settings/auto-cycle/stop", nil)
if fake.autoCycle.Enabled {
t.Fatalf("expected the fake control-api auto-cycle disabled after stop")
}
if got := filterRegistryItems(items, "1.1.1", ""); len(got) != 2 {
t.Fatalf("expected 2 matches for q=1.1.1, got %+v", got)
if !strings.Contains(body, "Автоцикл выключен") || !strings.Contains(body, "Включить") ||
!strings.Contains(body, "остановлен") {
t.Fatalf("expected the re-rendered panel to show the stopped state, got:\n%s", body)
}
if got := filterRegistryItems(items, "", "partial"); len(got) != 1 || got[0].IPAddress != "1.1.1.2" {
t.Fatalf("expected exactly the partial-result address, got %+v", got)
}
func TestAutoCyclePutConvertsMinutesToSeconds(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
body := postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"30"}, "max_run_minutes": {"10"},
})
if fake.autoCycle.IntervalSeconds != 1800 || fake.autoCycle.MaxRunSeconds != 600 {
t.Fatalf("expected 1800/600 seconds at control-api, got %d/%d",
fake.autoCycle.IntervalSeconds, fake.autoCycle.MaxRunSeconds)
}
if got := filterRegistryItems(items, "2.2.2", "partial"); len(got) != 0 {
t.Fatalf("expected q+status combined with AND to exclude non-matching, got %+v", got)
if !strings.Contains(body, `name="interval_minutes" min="1" step="any" value="30"`) ||
!strings.Contains(body, `name="max_run_minutes" min="0" step="any" value="10"`) {
t.Fatalf("expected updated minutes in the re-rendered form, got:\n%s", body)
}
if fake.autoCycle.Enabled {
t.Fatalf("saving parameters must not toggle the auto-cycle")
}
// 0 = no limit.
postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"30"}, "max_run_minutes": {"0"},
})
if fake.autoCycle.MaxRunSeconds != 0 {
t.Fatalf("expected max_run_seconds 0, got %d", fake.autoCycle.MaxRunSeconds)
}
}
func TestAutoCyclePutValidationErrors(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
// Below the control-api minimum (60 s): rejected by control-api with
// 400, surfaced as a warning banner; stored value is untouched.
body := postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"0.5"}, "max_run_minutes": {"0"},
})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for a too-short interval, got:\n%s", body)
}
if fake.autoCycle.IntervalSeconds != 3600 {
t.Fatalf("expected interval unchanged, got %d", fake.autoCycle.IntervalSeconds)
}
// Negative max run.
body = postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"10"}, "max_run_minutes": {"-5"},
})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for a negative max run, got:\n%s", body)
}
// Non-numeric input is caught by the dashboard itself.
body = postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"abc"}, "max_run_minutes": {"0"},
})
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "интервал должен быть числом минут") {
t.Fatalf("expected a Russian banner for a non-numeric interval, got:\n%s", body)
}
body = postForm(t, ts, "PUT", "/settings/auto-cycle", map[string][]string{
"interval_minutes": {"10"}, "max_run_minutes": {"abc"},
})
if !strings.Contains(body, "alert-warning") || !strings.Contains(body, "максимальная длительность") {
t.Fatalf("expected a Russian banner for a non-numeric max run, got:\n%s", body)
}
}
func TestOverviewShowsAutoCycleIndicatorWhenEnabled(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
next := time.Now().Add(30 * time.Minute)
fake.autoCycle = autoCycleDTO{Enabled: true, IntervalSeconds: 3600, Phase: "waiting", NextRunAt: &next}
ts := newTestServer(t, caURL)
page := get(t, ts, "/overview")
for _, want := range []string{"Автоцикл активен", "пауза между циклами", "следующий запуск"} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the overview indicator, got:\n%s", want, page)
}
}
// The indicator sits inside the stats block, so the DOM order
// stats -> filter -> tables is preserved.
indIdx := strings.Index(page, "Автоцикл активен")
filterIdx := strings.Index(page, `id="overview-filter"`)
tablesIdx := strings.Index(page, `id="overview-tables"`)
if !(indIdx < filterIdx && filterIdx < tablesIdx) {
t.Fatalf("expected indicator -> filter -> tables order, got %d/%d/%d", indIdx, filterIdx, tablesIdx)
}
// The polled fragment refreshes it through the stats OOB block.
frag := get(t, ts, "/overview/fragment")
oobIdx := strings.Index(frag, `id="overview-stats" hx-swap-oob="true"`)
if oobIdx < 0 || !strings.Contains(frag[oobIdx:], "Автоцикл активен") {
t.Fatalf("expected the indicator inside the OOB stats block, got:\n%s", frag)
}
}
func TestOverviewHidesAutoCycleIndicatorWhenDisabledOrUnavailable(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL)
// Disabled: no indicator.
if page := get(t, ts, "/overview"); strings.Contains(page, "Автоцикл активен") {
t.Fatalf("indicator must be hidden while the auto-cycle is disabled, got:\n%s", page)
}
// Enabled but the auto-cycle API fails: the overview still renders
// (non-fatal), without the indicator and without an error banner.
fake.mu.Lock()
fake.autoCycle.Enabled = true
fake.autoCycleDown = true
fake.mu.Unlock()
page := get(t, ts, "/overview")
if strings.Contains(page, "Автоцикл активен") {
t.Fatalf("indicator must be hidden when the auto-cycle API fails, got:\n%s", page)
}
if strings.Contains(page, "alert-") {
t.Fatalf("an auto-cycle failure must not raise an error banner on the overview, got:\n%s", page)
}
if !strings.Contains(page, "всего IP") {
t.Fatalf("expected the overview to still render its stats, got:\n%s", page)
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ func (s *Server) handleValidatorsPage(w http.ResponseWriter, r *http.Request) {
data := validatorsPageData{Items: items}
data.ActiveNav = "validators"
data.Banner = bannerFor(err)
s.renderPage(w, "validators_page", data)
s.renderPage(w, r, "validators_page", data)
}
func (s *Server) renderValidatorsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+118
View File
@@ -0,0 +1,118 @@
package dashboard
import (
"net/url"
"strconv"
"strings"
)
// Server-side pagination shared by /ips and /registry: `page` (1-based,
// clamped to the last page) and `per_page` (one of perPageOptions, else
// defaultPerPage).
const defaultPerPage = 50
var perPageOptions = []int{25, 50, 100, 200}
// parsePerPage accepts only the whitelisted page sizes.
func parsePerPage(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return defaultPerPage
}
for _, o := range perPageOptions {
if n == o {
return n
}
}
return defaultPerPage
}
// parsePage returns the 1-based page number; anything invalid is page 1.
func parsePage(raw string) int {
n, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || n < 1 {
return 1
}
return n
}
func pageCount(total, perPage int) int {
if total <= 0 || perPage <= 0 {
return 1
}
return (total + perPage - 1) / perPage
}
// clampPage limits page to the last non-empty page for the given total.
func clampPage(page, total, perPage int) int {
if last := pageCount(total, perPage); page > last {
page = last
}
if page < 1 {
page = 1
}
return page
}
// pagerData drives the shared "pager" template partial.
type pagerData struct {
// Wrap is the id of the swap-target element holding the table (the
// ‹ › links replace it via hx-select + hx-target).
Wrap string
Page, PerPage, Total, Pages, From, To int
PrevURL, NextURL string
}
// newPager builds the pager for page (already clamped) of total rows. base is
// the page path; params are the filters to preserve in the links (without
// page/per_page, which the pager sets itself).
func newPager(base, wrap string, params url.Values, page, perPage, total int) pagerData {
p := pagerData{Wrap: wrap, Page: page, PerPage: perPage, Total: total, Pages: pageCount(total, perPage)}
if total > 0 {
p.From = (page-1)*perPage + 1
p.To = page * perPage
if p.To > total {
p.To = total
}
}
if page > 1 {
p.PrevURL = pageURL(base, params, page-1, perPage)
}
if page < p.Pages {
p.NextURL = pageURL(base, params, page+1, perPage)
}
return p
}
// pageURL builds base?...&page=N&per_page=M with every value escaped by
// url.Values (so a search string like "a&b" can't smuggle in a parameter).
// Page 1 omits `page`.
func pageURL(base string, params url.Values, page, perPage int) string {
v := url.Values{}
for k, vals := range params {
for _, val := range vals {
if val != "" {
v.Add(k, val)
}
}
}
v.Set("per_page", strconv.Itoa(perPage))
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
return base + "?" + v.Encode()
}
// chunk splits list into slices of at most size elements.
func chunk(list []string, size int) [][]string {
var out [][]string
for len(list) > size {
out = append(out, list[:size])
list = list[size:]
}
if len(list) > 0 {
out = append(out, list)
}
return out
}
+6 -1
View File
@@ -155,6 +155,10 @@ type bannerData struct {
type PageData struct {
Banner bannerData
ActiveNav string
// AuthEnabled/User are filled in by renderPage (see withAuthInfo) so the
// sidebar can show the signed-in user and the logout button.
AuthEnabled bool
User string
}
func bannerFor(err error) bannerData {
@@ -172,8 +176,9 @@ func bannerFor(err error) bannerData {
// navigation. actionErr (if any — e.g. the primary control-api call for
// this page failed) is surfaced via the embedded PageData.Banner, which
// the caller must have already set via bannerFor.
func (s *Server) renderPage(w http.ResponseWriter, name string, data interface{}) {
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data = s.withAuthInfo(r, data)
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.Log.Error("render page", "template", name, "err", err)
}
+10
View File
@@ -3,6 +3,12 @@ package dashboard
import "net/http"
func (s *Server) routes(mux *http.ServeMux) {
// /login, /logout and /static/ are the only routes reachable without a
// session — see authMiddleware in auth.go.
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /overview", s.handleOverview)
@@ -12,6 +18,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /ips/{ip}", s.handleIPDetail)
mux.HandleFunc("POST /ips", s.handleIPsSubmit)
mux.HandleFunc("POST /ips/scan", s.handleIPsScan)
mux.HandleFunc("GET /ips/scan/status", s.handleIPsScanStatus)
mux.HandleFunc("POST /ips/{ip}/recheck", s.handleIPRecheck)
mux.HandleFunc("POST /ips/{ip}/cancel", s.handleIPCancel)
mux.HandleFunc("DELETE /ips/{ip}", s.handleIPDelete)
@@ -45,6 +52,9 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /settings", s.handleSettingsPage)
mux.HandleFunc("PUT /settings", s.handleSettingsPut)
mux.HandleFunc("PUT /settings/inbound-checks", s.handleInboundChecksPut)
mux.HandleFunc("PUT /settings/auto-cycle", s.handleAutoCyclePut)
mux.HandleFunc("POST /settings/auto-cycle/start", s.handleAutoCycleStart)
mux.HandleFunc("POST /settings/auto-cycle/stop", s.handleAutoCycleStop)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS())))
}
+685
View File
@@ -0,0 +1,685 @@
package dashboard
import (
"fmt"
"html"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
// Tests for the scale features: background scan panel, server-side
// pagination/filters on /ips and /registry, bulk selection by filter, and the
// bounded overview.
func ipRowLink(ip string) string { return `href="/ips/` + ip + `"` }
func countRows(body string) int { return strings.Count(body, `name="addresses" value="`) }
func hasHXTriggerEvery(body string) bool { return strings.Contains(body, `hx-trigger="every 2s"`) }
var hrefRe = regexp.MustCompile(`href="([^"]*)"[^>]*rel="(prev|next)"`)
// pagerLink extracts the (unescaped, parsed) prev/next link of the pager.
func pagerLink(t *testing.T, body, rel string) *url.URL {
t.Helper()
for _, m := range hrefRe.FindAllStringSubmatch(body, -1) {
if m[2] == rel {
u, err := url.Parse(html.UnescapeString(m[1]))
if err != nil {
t.Fatalf("parse pager link %q: %v", m[1], err)
}
return u
}
}
return nil
}
func TestIPsScan(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanFreeAddresses = []string{"5.5.5.5", "5.5.5.6"}
fake.scanRunPolls = 2
ts := newTestServer(t, caURL)
// Start: answers at once with the panel; it polls itself, the scan
// buttons are disabled and nothing is queued yet.
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
for _, want := range []string{
`id="scan-progress"`, `hx-get="/ips/scan/status"`, `hx-trigger="every 2s"`, "читаются страницы",
`<progress aria-label="Сканирование"></progress>`, ` disabled`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the running panel, got:\n%s", want, body)
}
}
if resp.Header.Get("HX-Trigger") != "" {
t.Fatalf("a running scan must not fire scan-finished, got %q", resp.Header.Get("HX-Trigger"))
}
if len(fake.ips) != 0 {
t.Fatalf("nothing should be queued before the job finishes, got %+v", fake.ips)
}
// First poll: still running, keeps polling.
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
if !hasHXTriggerEvery(body) || resp.Header.Get("HX-Trigger") != "" {
t.Fatalf("expected a still-polling panel without HX-Trigger, got %q:\n%s", resp.Header.Get("HX-Trigger"), body)
}
// Second poll: finished — no polling trigger, HX-Trigger tells the table to reload.
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
if hasHXTriggerEvery(body) || strings.Contains(body, `hx-get="/ips/scan/status"`) {
t.Fatalf("a finished panel must stop polling, got:\n%s", body)
}
if got := resp.Header.Get("HX-Trigger"); got != "scan-finished" {
t.Fatalf("HX-Trigger = %q, want scan-finished", got)
}
for _, want := range []string{"готово", "<dt>добавлено</dt><dd>2</dd>", `<progress max="1" value="1"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the finished panel, got:\n%s", want, body)
}
}
if strings.Contains(body, " disabled") {
t.Fatalf("buttons must be enabled again once finished, got:\n%s", body)
}
if len(fake.ips) != 2 {
t.Fatalf("expected both scanned addresses queued, got %+v", fake.ips)
}
// The table reload target: #ips-table-wrap listens for scan-finished and
// re-fetches the current /ips URL.
page := get(t, ts, "/ips")
if !strings.Contains(page, `hx-trigger="scan-finished from:body"`) || !strings.Contains(page, `hx-select="#ips-table-wrap"`) {
t.Fatalf("expected #ips-table-wrap to reload on scan-finished, got:\n%s", page)
}
if !strings.Contains(page, ipRowLink("5.5.5.5")) {
t.Fatalf("expected the scanned address in the table, got:\n%s", page)
}
}
func TestIPsScanErrorAndDryRunAndRefusal(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanFreeAddresses = []string{"5.5.5.5"}
fake.scanFinalError = "openstack: list floating ips: boom"
ts := newTestServer(t, caURL)
// A job that fails: the panel shows the error and stops polling.
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
for _, want := range []string{"ошибка", "openstack: list floating ips: boom", `class="scan-error"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the error panel, got:\n%s", want, body)
}
}
if hasHXTriggerEvery(body) || resp.Header.Get("HX-Trigger") != "scan-finished" {
t.Fatalf("an errored job must stop polling and signal scan-finished, got %q:\n%s", resp.Header.Get("HX-Trigger"), body)
}
if len(fake.ips) != 0 {
t.Fatalf("a failed scan must not queue anything, got %+v", fake.ips)
}
// Dry run: the flag reaches control-api, the queue stays untouched.
fake.mu.Lock()
fake.scanFinalError = ""
fake.scan = scanStatusDTO{}
fake.scanRunPolls = 1
fake.mu.Unlock()
_, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan?dry_run=true"})
if !strings.Contains(body, "пробный запуск") {
t.Fatalf("expected the dry-run marker, got:\n%s", body)
}
_, body = doReq(t, ts, reqOpts{path: "/ips/scan/status"})
if !strings.Contains(body, "готово") || len(fake.ips) != 0 {
t.Fatalf("dry run must finish without queueing, queue=%+v body:\n%s", fake.ips, body)
}
// control-api refuses to start: banner, no polling.
fake.mu.Lock()
fake.scanStartStatus = http.StatusConflict
fake.mu.Unlock()
_, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan"})
if !strings.Contains(body, "alert-warning") || hasHXTriggerEvery(body) {
t.Fatalf("expected a client-error banner and no polling, got:\n%s", body)
}
}
func TestIPsPageRendersRunningScanPanelOutsideForm(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
fake.scan = scanStatusDTO{State: "enqueuing", Running: true, Pages: 33, Discovered: 6440, Free: 6440, Added: 1200, StartedAt: &now}
ts := newTestServer(t, caURL)
page := get(t, ts, "/ips")
for _, want := range []string{`hx-get="/ips/scan/status"`, "ставятся в очередь", `<progress max="6440" value="1200"`, "<dt>прочитано страниц</dt><dd>33</dd>"} {
if !strings.Contains(page, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, page)
}
}
if strings.Index(page, `id="scan-progress"`) > strings.Index(page, `<form id="ips-form"`) {
t.Fatalf("the scan panel must sit above (outside) #ips-form")
}
}
func TestPageURLEscapesQuery(t *testing.T) {
got := pageURL("/ips", url.Values{"q": {"a b&c=d"}, "state": {"queued"}}, 3, 100)
u, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if u.Path != "/ips" || q.Get("q") != "a b&c=d" || q.Get("state") != "queued" || q.Get("page") != "3" || q.Get("per_page") != "100" || len(q) != 4 {
t.Fatalf("unexpected URL %q (query %v)", got, q)
}
if first := pageURL("/ips", nil, 1, 50); strings.Contains(first, "page=") && !strings.Contains(first, "per_page=") {
t.Fatalf("page 1 must not carry page=, got %q", first)
}
}
func TestIPsPagination(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(120)
ts := newTestServer(t, caURL)
body := get(t, ts, "/ips")
if !strings.Contains(body, "Показано 1–50 из 120") || countRows(body) != 50 {
t.Fatalf("page 1: want «Показано 1–50 из 120» and 50 rows, got %d rows:\n%s", countRows(body), body)
}
if next := pagerLink(t, body, "next"); next == nil || next.Query().Get("page") != "2" {
t.Fatalf("expected a next link to page 2, got %v", next)
}
if pagerLink(t, body, "prev") != nil {
t.Fatalf("page 1 must have no prev link")
}
body = get(t, ts, "/ips?page=2")
if !strings.Contains(body, "Показано 51–100 из 120") || countRows(body) != 50 {
t.Fatalf("page 2: got:\n%s", body)
}
if prev := pagerLink(t, body, "prev"); prev == nil || prev.Query().Get("page") != "" {
t.Fatalf("page 2 must link back to page 1 (no page param), got %v", prev)
}
// Last page, and a page past the end clamps to it; junk is page 1.
for _, p := range []string{"3", "99"} {
body = get(t, ts, "/ips?page="+p)
if !strings.Contains(body, "Показано 101–120 из 120") || countRows(body) != 20 || pagerLink(t, body, "next") != nil {
t.Fatalf("page=%s: want the last page, got:\n%s", p, body)
}
}
for _, p := range []string{"0", "-4", "abc"} {
if body = get(t, ts, "/ips?page="+p); !strings.Contains(body, "Показано 1–50 из 120") {
t.Fatalf("page=%s: want page 1, got:\n%s", p, body)
}
}
// per_page: only 25/50/100/200 are accepted, anything else is the default.
for per, want := range map[string]int{"25": 25, "100": 100, "200": 120, "77": 50, "": 50, "x": 50} {
if body = get(t, ts, "/ips?per_page="+per); countRows(body) != want {
t.Fatalf("per_page=%q: want %d rows, got %d", per, want, countRows(body))
}
}
}
func TestIPsPaginationKeepsFilterInLinks(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(120)
ts := newTestServer(t, caURL)
// 10.0.0.1 matches .1, .10-.19, .100-.120: 32 rows.
body := get(t, ts, "/ips?q=10.0.0.1&state=queued&per_page=25")
if !strings.Contains(body, "Показано 1–25 из 32") {
t.Fatalf("expected the filtered total, got:\n%s", body)
}
next := pagerLink(t, body, "next")
if next == nil {
t.Fatalf("expected a next link")
}
q := next.Query()
if q.Get("q") != "10.0.0.1" || q.Get("state") != "queued" || q.Get("per_page") != "25" || q.Get("page") != "2" {
t.Fatalf("next link lost the filter: %v", next)
}
// The same filter is carried by the hidden inputs of #ips-form and by the
// wrapper's own reload URL.
for _, want := range []string{
`name="q" value="10.0.0.1"`, `name="state" value="queued"`, `name="per_page" value="25"`, `name="page" value="1"`,
`hx-get="/ips?per_page=25&amp;q=10.0.0.1&amp;state=queued"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, body)
}
}
}
func TestIPsFilters(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
mk := func(ip, state, result string, seq int) ipQueueItem {
return ipQueueItem{IPAddress: ip, State: state, OverallResult: result, Sequence: seq, AggregatedAt: &now, CreatedAt: now, UpdatedAt: now}
}
fake.ips = []ipQueueItem{
mk("1.1.1.1", "queued", "", 1), mk("1.1.1.2", "checking", "", 2), mk("1.1.1.3", "awaiting_self_check", "", 3),
mk("2.2.2.1", "done", "pass", 4), mk("2.2.2.2", "failed", "fail", 5), mk("2.2.2.3", "failed", "cancelled", 6),
mk("3.3.3.3", "occupied", "", 7),
}
ts := newTestServer(t, caURL)
cases := []struct {
query string
want []string
}{
{"", []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"}},
{"state=queued", []string{"1.1.1.1"}},
{"state=active", []string{"1.1.1.2", "1.1.1.3"}},
{"state=done", []string{"2.2.2.1"}},
{"state=failed", []string{"2.2.2.2", "2.2.2.3"}},
{"state=occupied", []string{"3.3.3.3"}},
{"state=result:fail", []string{"2.2.2.2"}},
{"result=cancelled", []string{"2.2.2.3"}},
{"state=failed&result=cancelled", []string{"2.2.2.3"}},
{"q=1.1.1&state=active", []string{"1.1.1.2", "1.1.1.3"}},
{"state=bogus", []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"}},
}
for _, c := range cases {
body := get(t, ts, "/ips?"+c.query)
got := map[string]bool{}
for _, ip := range []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "2.2.2.1", "2.2.2.2", "2.2.2.3", "3.3.3.3"} {
got[ip] = strings.Contains(body, ipRowLink(ip))
}
for ip, in := range got {
if want := containsStr(c.want, ip); in != want {
t.Fatalf("?%s: row %s present=%v, want %v", c.query, ip, in, want)
}
}
}
// The select echoes the active filter; an empty filtered result says so.
if body := get(t, ts, "/ips?state=result:fail"); !strings.Contains(body, `value="result:fail" selected`) {
t.Fatalf("expected the result option to be selected, got:\n%s", body)
}
if body := get(t, ts, "/ips?q=zzz"); !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
t.Fatalf("expected the filtered-empty message, got:\n%s", body)
}
// An htmx filter request gets just the swappable wrapper, not the page.
_, frag := doReq(t, ts, reqOpts{path: "/ips?state=queued", headers: map[string]string{"HX-Request": "true"}})
if strings.Contains(frag, "<html") || !strings.Contains(frag, `id="ips-table-wrap"`) || !strings.Contains(frag, ipRowLink("1.1.1.1")) {
t.Fatalf("expected the table wrapper fragment, got:\n%s", frag)
}
// Filters are applied server-side: control-api got them as parameters.
fake.mu.Lock()
defer fake.mu.Unlock()
var sawState bool
for _, q := range fake.ipsQueries {
if strings.Contains(q, "state=queued") && strings.Contains(q, "limit=50") {
sawState = true
}
}
if !sawState || fake.bareIPsCalls != 0 {
t.Fatalf("expected paginated filtered requests only (bare=%d), got %v", fake.bareIPsCalls, fake.ipsQueries)
}
}
func TestIPsMutationsKeepPageAndFilter(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
addrs := fake.seedIPs(120)
ts := newTestServer(t, caURL)
// Per-row delete on page 2 (the URL carries only the context params).
_, body := doReq(t, ts, reqOpts{method: http.MethodDelete, path: "/ips/" + addrs[60] + "?page=2&per_page=50&state=queued"})
if !strings.Contains(body, "Показано 51–100 из 119") || strings.Contains(body, ipRowLink(addrs[60])) {
t.Fatalf("delete must re-render page 2 of the same filter, got:\n%s", body)
}
for _, want := range []string{`name="page" value="2"`, `name="state" value="queued"`} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the re-rendered form, got:\n%s", want, body)
}
}
// Per-row recheck keeps the page too.
body = postForm(t, ts, "POST", "/ips/"+addrs[70]+"/recheck", url.Values{"page": {"2"}, "per_page": {"50"}})
if !strings.Contains(body, "Показано 51–100 из 119") {
t.Fatalf("recheck must stay on page 2, got:\n%s", body)
}
// Deleting every row of the last page clamps to the last non-empty page.
fake.mu.Lock()
var last []string
for _, ip := range fake.ips[100:] {
last = append(last, ip.IPAddress)
}
fake.mu.Unlock()
body = postForm(t, ts, "POST", "/ips/delete", url.Values{"page": {"3"}, "per_page": {"50"}, "addresses": last})
if !strings.Contains(body, "Показано 51–100 из 100") || !strings.Contains(body, `name="page" value="2"`) {
t.Fatalf("expected a clamp to page 2 after the last page emptied, got:\n%s", body)
}
// Adding addresses through the top form keeps the context hidden inputs' page too.
body = postForm(t, ts, "POST", "/ips", url.Values{"addresses": {"9.9.9.9"}, "page": {"2"}, "per_page": {"50"}})
if !strings.Contains(body, "Показано 51–100 из 101") {
t.Fatalf("expected page 2 of 101 after adding, got:\n%s", body)
}
}
func TestIPsBulkScopeAllResolvesByFilterInChunks(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(1300)
now := time.Now()
fake.mu.Lock()
for _, a := range []string{"2.2.2.1", "2.2.2.2", "2.2.2.3"} {
fake.ips = append(fake.ips, ipQueueItem{IPAddress: a, State: "done", OverallResult: "pass", AggregatedAt: &now, CreatedAt: now, UpdatedAt: now})
}
fake.mu.Unlock()
ts := newTestServer(t, caURL)
// Recheck everything in state=done: three addresses, one chunk.
postForm(t, ts, "POST", "/ips/recheck", url.Values{"scope": {"all"}, "state": {"done"}})
if len(fake.submitChunks) != 1 || fake.submitChunks[0] != 3 {
t.Fatalf("submit chunks = %v, want [3]", fake.submitChunks)
}
// Delete all 1300 queued+rechecked rows matching q=10. (the checked
// boxes of the page are ignored under scope=all): chunks of ≤500.
body := postForm(t, ts, "POST", "/ips/delete", url.Values{
"scope": {"all"}, "q": {"10."}, "state": {"queued"}, "per_page": {"50"}, "addresses": {"2.2.2.1"},
})
if got := fmt.Sprint(fake.deleteChunks); got != "[500 500 300]" {
t.Fatalf("delete chunks = %s, want [500 500 300]", got)
}
fake.mu.Lock()
left := len(fake.ips)
var big bool
for _, q := range fake.ipsQueries {
v, _ := url.ParseQuery(q)
if n, _ := strconv.Atoi(v.Get("limit")); n > 1000 || n == 0 {
big = true
}
}
bare := fake.bareIPsCalls
fake.mu.Unlock()
if left != 3 {
t.Fatalf("expected only the 3 non-matching rows left, got %d", left)
}
if big || bare != 0 {
t.Fatalf("every list call must be paginated with limit ≤ 1000, got %v (bare=%d)", fake.ipsQueries, bare)
}
if !strings.Contains(body, "Ничего не найдено по текущему фильтру") {
t.Fatalf("expected the emptied filter view, got:\n%s", body)
}
// scope=all with nothing matching is a client error, not a silent no-op.
body = postForm(t, ts, "POST", "/ips/delete", url.Values{"scope": {"all"}, "q": {"nothing"}})
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected a banner for an empty scope=all, got:\n%s", body)
}
}
func TestIPsTableControls(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(120)
now := time.Now()
fake.mu.Lock()
fake.ips[0].State = "checking"
fake.ips[1].State = "done"
fake.ips[1].OverallResult = "pass"
fake.ips[1].AggregatedAt = &now
fake.mu.Unlock()
ts := newTestServer(t, caURL)
body := get(t, ts, "/ips")
const params = `hx-params="page,per_page,q,state,result"`
// Every per-row button and the clear/scan buttons restrict the request
// params (htmx would otherwise put every checked checkbox into the URL).
var rowButtons int
for _, b := range strings.Split(body, "<button")[1:] {
tag := b[:strings.Index(b, ">")]
perRow := strings.Contains(tag, `hx-delete="/ips/`) ||
(strings.Contains(tag, `hx-post="/ips/1`) && (strings.Contains(tag, "/recheck") || strings.Contains(tag, "/cancel")))
if perRow {
rowButtons++
}
if perRow || strings.Contains(tag, `hx-post="/ips/clear"`) || strings.Contains(tag, `hx-post="/ips/scan`) {
if !strings.Contains(tag, params) {
t.Fatalf("button without hx-params: <button%s>", tag)
}
}
}
if rowButtons != 100 { // delete + recheck/cancel on each of the 50 rows
t.Fatalf("expected 100 per-row buttons, found %d", rowButtons)
}
// Bulk delete/recheck submit the whole form (checked boxes + scope), so no whitelist there.
for _, b := range strings.Split(body, "<button")[1:] {
tag := b[:strings.Index(b, ">")]
if (strings.Contains(tag, `hx-post="/ips/delete"`) || strings.Contains(tag, `hx-post="/ips/recheck"`)) && strings.Contains(tag, "hx-params") {
t.Fatalf("bulk buttons must submit the whole form: <button%s>", tag)
}
}
// Bulk selection UI with real counts.
for _, want := range []string{
"Выбрано на странице: <b data-sel-count>0</b> из 50",
"Выбрать все 120 по фильтру",
"Удалить ВСЕ 120 адресов, включая идущие проверки? Действие необратимо.",
"Удалить ВСЕ 120 адресов из очереди, включая идущие проверки? Действие необратимо.",
`<input type="hidden" name="scope" value="">`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the page, got:\n%s", want, body)
}
}
// Under a filter the clear confirmation shows the whole queue, the
// delete-all one the filtered count.
body = get(t, ts, "/ips?q=10.0.0.1")
for _, want := range []string{"Удалить ВСЕ 32 адреса по текущему фильтру, включая", "Удалить ВСЕ 120 адресов из очереди"} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q under a filter, got:\n%s", want, body)
}
}
// Everything on one page: no "select all by filter" offer.
body = get(t, ts, "/ips?q=10.0.0.119")
if strings.Contains(body, `class="select-all-link"`) {
t.Fatalf("no select-all link when the filter fits on the page, got:\n%s", body)
}
}
func TestOverviewBoundedWithThousandsQueued(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(5000)
now := time.Now()
fake.mu.Lock()
for i := 0; i < 3; i++ {
fake.ips[i].State = "checking"
}
for i := 0; i < 30; i++ {
at := now.Add(-time.Duration(i) * time.Minute)
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("2.2.2.%d", i), State: "done", OverallResult: "pass", AggregatedAt: &at, CreatedAt: now, UpdatedAt: now})
}
fake.mu.Unlock()
ts := newTestServer(t, caURL)
for _, path := range []string{"/overview", "/overview/fragment"} {
body := get(t, ts, path)
// 3 active + the next 10 queued + the last 20 completed.
if n := strings.Count(body, `href="/ips/`); n != 33 {
t.Fatalf("%s: expected 33 address rows (3+10+20), got %d", path, n)
}
for _, want := range []string{"В очереди: <b>4997</b>", `href="/ips?state=queued"`, "Последние 20 завершённых", "Ближайшие в очереди"} {
if !strings.Contains(body, want) {
t.Fatalf("%s: expected %q, got:\n%s", path, want, body)
}
}
if !strings.Contains(body, ipRowLink("2.2.2.0")) || strings.Contains(body, ipRowLink("2.2.2.25")) {
t.Fatalf("%s: expected only the 20 newest completed rows", path)
}
}
fake.mu.Lock()
defer fake.mu.Unlock()
if fake.bareIPsCalls != 0 {
t.Fatalf("the overview must never load the whole queue, bare calls = %d", fake.bareIPsCalls)
}
if len(fake.ipsQueries) == 0 {
t.Fatalf("expected paginated list requests")
}
for _, q := range fake.ipsQueries {
v, _ := url.ParseQuery(q)
if n, _ := strconv.Atoi(v.Get("limit")); n < 1 || n > 100 {
t.Fatalf("overview list request without a small limit: %q", q)
}
}
}
func TestOverviewProgressIndicatorAndETA(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(100)
now := time.Now()
fake.mu.Lock()
// 6 completed rows, one every 10 s (span 50 s): 10 s per address.
for i := 0; i < 6; i++ {
at := now.Add(-time.Duration(i) * 10 * time.Second)
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("2.2.2.%d", i), State: "done", OverallResult: "pass", AggregatedAt: &at, CreatedAt: now, UpdatedAt: now})
}
// "occupied" counts as finished work too.
for i := 0; i < 2; i++ {
fake.ips = append(fake.ips, ipQueueItem{IPAddress: fmt.Sprintf("3.3.3.%d", i), State: "occupied", CreatedAt: now, UpdatedAt: now})
}
fake.mu.Unlock()
ts := newTestServer(t, caURL)
body := get(t, ts, "/overview")
for _, want := range []string{
"Готово 8 из 108 (7%) · в работе 0 · в очереди 100",
`<progress max="108" value="8"`,
"осталось ≈ 16 мин 40 с", // 100 queued × 10 s
"Итоги проверок: pass 6",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in the stats block, got:\n%s", want, body)
}
}
// The ETA is not derived from a filtered window.
if body = get(t, ts, "/overview?q=2.2.2"); strings.Contains(body, "осталось") {
t.Fatalf("no ETA under a filter, got:\n%s", body)
}
// Too few samples: no ETA.
fake.mu.Lock()
fake.ips = append(fake.ips[:100], fake.ips[100:103]...)
fake.mu.Unlock()
if body = get(t, ts, "/overview"); strings.Contains(body, "осталось") || !strings.Contains(body, "Готово 3 из 103") {
t.Fatalf("expected progress without ETA for <5 samples, got:\n%s", body)
}
}
func TestOverviewScanLineAndScanningPhase(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.seedIPs(2)
now := time.Now()
fake.autoCycle = autoCycleDTO{Enabled: true, IntervalSeconds: 3600, Phase: "scanning", NextRunAt: &now}
ts := newTestServer(t, caURL)
// No running scan: no scan line.
if body := get(t, ts, "/overview"); strings.Contains(body, "Сканирование:") {
t.Fatalf("no scan line while idle, got:\n%s", body)
}
fake.mu.Lock()
fake.scan = scanStatusDTO{State: "enqueuing", Running: true, Pages: 33, Discovered: 6440, Free: 6439, Added: 500, StartedAt: &now}
fake.mu.Unlock()
body := get(t, ts, "/overview")
for _, want := range []string{"Сканирование: ставятся в очередь", "прочитано страниц 33", "найдено адресов 6440", "свободных 6439", "сканирование Floating IP", "Автоцикл активен"} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q, got:\n%s", want, body)
}
}
// The polled fragment refreshes the line via the OOB stats block.
frag := get(t, ts, "/overview/fragment")
oob := strings.Index(frag, `id="overview-stats" hx-swap-oob="true"`)
if oob < 0 || !strings.Contains(frag[oob:], "Сканирование: ставятся в очередь") {
t.Fatalf("expected the scan line inside the OOB stats block, got:\n%s", frag)
}
}
func TestRegistryPaginationAndServerSideFilter(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
now := time.Now()
results := []string{"pass", "fail", "partial"}
for i := 0; i < 120; i++ {
ip := fmt.Sprintf("10.0.0.%d", i+1)
fake.registry[ip] = registryItem{IPAddress: ip, FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: results[i%3]}
}
ts := newTestServer(t, caURL)
body := get(t, ts, "/registry?page=2")
if !strings.Contains(body, "Показано 51–100 из 120") || strings.Count(body, `href="/registry/10.`) != 50 {
t.Fatalf("expected page 2 with 50 rows, got:\n%s", body)
}
if body = get(t, ts, "/registry?page=99"); !strings.Contains(body, "Показано 101–120 из 120") {
t.Fatalf("expected a clamp to the last page, got:\n%s", body)
}
// 40 of the 120 are "fail"; q and status survive in the pager links and
// reach control-api as parameters (no client-side filtering).
body = get(t, ts, "/registry?status=fail&q=10.0.0&per_page=25")
if !strings.Contains(body, "Показано 1–25 из 40") {
t.Fatalf("expected the server-side filtered total, got:\n%s", body)
}
next := pagerLink(t, body, "next")
if next == nil || next.Path != "/registry" || next.Query().Get("status") != "fail" || next.Query().Get("q") != "10.0.0" ||
next.Query().Get("per_page") != "25" || next.Query().Get("page") != "2" {
t.Fatalf("pager link lost the filter: %v", next)
}
fake.mu.Lock()
defer fake.mu.Unlock()
last := fake.registryQueries[len(fake.registryQueries)-1]
for _, want := range []string{"last_result=fail", "q=10.0.0", "limit=25"} {
if !strings.Contains(last, want) {
t.Fatalf("control-api request %q lacks %s", last, want)
}
}
}
func TestLongTimeoutForClearAndBulkCalls(t *testing.T) {
// A short per-call timeout (50 ms) breaks plain reads of a slow control-api
// but not clear/bulk operations, which use the long client.
slow := newSlowAPI(t, 200*time.Millisecond)
c := newClient(slow, 50*time.Millisecond)
if _, err := c.Status(t.Context()); err == nil {
t.Fatalf("expected the short timeout to fail a plain read")
}
if _, err := c.ClearQueue(t.Context()); err != nil {
t.Fatalf("ClearQueue must use the long timeout: %v", err)
}
if _, err := c.DeleteIPs(t.Context(), []string{"1.1.1.1"}); err != nil {
t.Fatalf("DeleteIPs must use the long timeout: %v", err)
}
if _, err := c.SubmitIPs(t.Context(), []string{"1.1.1.1"}); err != nil {
t.Fatalf("SubmitIPs must use the long timeout: %v", err)
}
}
func TestScanRoutesPassOriginCheckWhenAuthEnabled(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
fake.scanRunPolls = 1
_, ts := newAuthTestServer(t, caURL, nil)
cookie := login(t, ts)
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan", cookie: cookie})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("scan without Origin: status=%d, want 403", resp.StatusCode)
}
resp, body = doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/scan", cookie: cookie, headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="scan-progress"`) {
t.Fatalf("scan with Origin: status=%d body:\n%s", resp.StatusCode, body)
}
resp, body = doReq(t, ts, reqOpts{path: "/ips/scan/status", cookie: cookie})
if resp.StatusCode != http.StatusOK || resp.Header.Get("HX-Trigger") != "scan-finished" {
t.Fatalf("status poll: status=%d trigger=%q body:\n%s", resp.StatusCode, resp.Header.Get("HX-Trigger"), body)
}
}
+20 -2
View File
@@ -18,6 +18,19 @@ type Config struct {
ControlAPITimeout time.Duration
LastCompletedCount int
OverviewPollIntervalS int
// ControlAPIToken is the admin bearer token sent to control-api. Empty =
// no Authorization header.
ControlAPIToken string
// Username/Password are the single dashboard administrator's login. If
// either is empty, login is DISABLED (every page is open).
Username string
Password string
// SessionSecret is the HMAC key for session cookies; empty = random per
// process start (sessions are lost on restart).
SessionSecret string
// SessionTTL is the session lifetime (default 8h).
SessionTTL time.Duration
}
type Server struct {
@@ -25,6 +38,7 @@ type Server struct {
Cfg Config
tmpl *template.Template
Log *slog.Logger
auth *authState
}
func New(cfg Config, log *slog.Logger) (*Server, error) {
@@ -32,18 +46,22 @@ func New(cfg Config, log *slog.Logger) (*Server, error) {
if err != nil {
return nil, err
}
ca := newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout)
ca.token = cfg.ControlAPIToken
return &Server{
CA: newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout),
CA: ca,
Cfg: cfg,
tmpl: tmpl,
Log: log,
auth: newAuthState(cfg, log),
}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return loggingMiddleware(s.Log, mux)
// Never log cookies, Authorization or form bodies here.
return loggingMiddleware(s.Log, s.authMiddleware(mux))
}
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
+66 -1
View File
@@ -337,7 +337,7 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
}
.form-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
input[type="text"], input[type="number"], input[type="search"], textarea, select {
input[type="text"], input[type="password"], input[type="number"], input[type="search"], textarea, select {
font-family: var(--font-ui);
font-size: 13.5px;
padding: 7px 10px;
@@ -544,3 +544,68 @@ tr.edit-row textarea { flex: 1 1 auto; }
Height is set by JS (see templates/targets.html), computed from the
field's own line-height, so it stays correct if font-size ever changes. */
textarea.autosize { overflow-y: hidden; resize: none; }
/* ---------- login page & sidebar user ---------- */
.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px 16px; position: relative; }
.login-card { width: 100%; max-width: 380px; }
.login-card h1 { font-size: 18px; margin: 0 0 4px; }
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; }
/* .field is flex: 1 1 220px (for rows); in this column the basis would become a 220px height. */
.login-card .field { flex: 0 0 auto; }
.sidebar-user {
margin-top: auto; margin-bottom: 8px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-family: var(--font-display); font-size: 11px; color: var(--text-muted);
}
.sidebar-user-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-user + .sidebar-foot { margin-top: 0; }
/* ---------- scan progress, progress bars, pager, bulk selection ---------- */
progress {
appearance: none; -webkit-appearance: none;
display: block; width: 100%; height: 8px;
border: 0; border-radius: var(--radius-sm);
background: var(--surface-alt); color: var(--accent);
overflow: hidden;
}
progress::-webkit-progress-bar { background: var(--surface-alt); border-radius: var(--radius-sm); }
progress::-webkit-progress-value { background: var(--accent); border-radius: var(--radius-sm); }
progress::-moz-progress-bar { background: var(--accent); border-radius: var(--radius-sm); }
/* Indeterminate (no value attribute): a sliding stripe, Firefox/Chromium. */
progress:indeterminate { background: linear-gradient(90deg, var(--surface-alt) 0, var(--accent-soft) 40%, var(--surface-alt) 80%); background-size: 200% 100%; animation: progress-slide 1.4s linear infinite; }
progress:indeterminate::-webkit-progress-value { background: transparent; }
progress:indeterminate::-moz-progress-bar { background: transparent; }
@keyframes progress-slide { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { progress:indeterminate { animation: none; } }
.overall-progress { margin-top: 12px; font-size: 12.5px; color: var(--text-muted); }
.overall-progress p { margin-bottom: 6px; }
.scan-progress { margin-top: 16px; }
.scan-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.scan-progress progress { margin-bottom: 10px; }
.scan-counters { display: flex; flex-wrap: wrap; gap: 6px 22px; font-size: 12.5px; color: var(--text-muted); }
.scan-counters dt { display: inline; }
.scan-counters dd { display: inline; margin: 0 0 0 4px; color: var(--text); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.scan-error { margin-top: 8px; padding: 8px 10px; border: 1px solid var(--danger-border); border-radius: var(--radius-xs); background: var(--danger-soft); color: var(--danger); font-size: 12.5px; }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 12px; font-size: 12.5px; color: var(--text-muted); }
.pager-nav { display: inline-flex; align-items: center; gap: 8px; }
.pager-page { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.btn.is-disabled { opacity: .4; pointer-events: none; }
.panel > .pager { margin: 0; padding: 10px 16px; border-top: 1px solid var(--border-soft); }
.bulk-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.bulk-hint { padding: 8px 16px; font-size: 12.5px; color: var(--text-muted); border-top: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); background: var(--surface-alt); }
.bulk-hint b { color: var(--text); font-family: var(--font-mono); font-weight: 500; }
.select-all-link, .scope-all-msg, .btn-del-all { display: none; }
#ips-form.sel-all .select-all-link { display: inline; margin-left: 10px; }
#ips-form.scope-all .select-all-link { display: none; }
#ips-form.scope-all .scope-all-msg { display: inline; margin-left: 10px; color: var(--warning); }
#ips-form.scope-all .btn-del-all { display: inline-flex; }
#ips-form.scope-all .btn-del-sel { display: none; }
.queue-line { margin: 14px 0 8px; font-size: 13px; color: var(--text-muted); }
.queue-line b { color: var(--text); font-family: var(--font-mono); font-weight: 500; }
table.compact td, table.compact th { padding-top: 6px; padding-bottom: 6px; }
+126 -15
View File
@@ -24,7 +24,7 @@
<div class="panel">
<div class="panel-body">
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-on::after-request="this.reset()">
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-include=".ips-ctx" hx-sync="#ips-table-wrap:queue last" hx-on::after-request="this.reset()">
<div class="field-row">
<div class="field" style="flex: 1 1 420px;">
<label for="addresses">Адреса (по одному на строку или через запятую)</label>
@@ -36,28 +36,127 @@
<p class="muted" style="margin-top:8px">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
(тот же вызов); тот, что сейчас проверяется, не трогается. Кнопка «Сканировать Floating IP» ниже делает то же самое
автоматически: находит в проекте OpenStack все свободные (не привязанные к порту) Floating IP и сразу передаёт их
в очередь на проверку. Полная история проверок по каждому адресу — включая уже удалённые из очереди — доступна в
<a href="/registry">реестре</a>.</p>
в очередь на проверку — сканирование идёт в фоне, прогресс виден в панели под кнопкой. Полная история проверок по
каждому адресу — включая уже удалённые из очереди — доступна в <a href="/registry">реестре</a>.</p>
</div>
</div>
<div id="ips-table-wrap">
{{template "scan_progress" .Scan}}
<form id="ips-filter" class="panel" onsubmit="return false" style="margin-top:16px">
<div class="panel-body field-row">
<div class="field" style="flex:1 1 260px">
<label for="ips-q">Поиск по IP</label>
<input type="search" id="ips-q" name="q" value="{{.Filter.Q}}" placeholder="203.0.113.10"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="input changed delay:300ms"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
</div>
<div class="field">
<label for="ips-state">Состояние</label>
<select id="ips-state" name="state"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
{{$tok := .Filter.Token}}
<option value="">Все</option>
<option value="queued" {{if eq $tok "queued"}}selected{{end}}>в очереди (queued)</option>
<option value="active" {{if eq $tok "active"}}selected{{end}}>в работе (assigning_fip, awaiting_self_check, checking, aggregating)</option>
<option value="done" {{if eq $tok "done"}}selected{{end}}>done</option>
<option value="failed" {{if eq $tok "failed"}}selected{{end}}>failed</option>
<option value="occupied" {{if eq $tok "occupied"}}selected{{end}}>occupied</option>
<option value="result:pass" {{if eq $tok "result:pass"}}selected{{end}}>результат: pass</option>
<option value="result:partial" {{if eq $tok "result:partial"}}selected{{end}}>результат: partial</option>
<option value="result:fail" {{if eq $tok "result:fail"}}selected{{end}}>результат: fail</option>
<option value="result:cancelled" {{if eq $tok "result:cancelled"}}selected{{end}}>результат: cancelled</option>
</select>
</div>
<div class="field" style="flex:0 0 120px">
<label for="ips-per-page">На странице</label>
<select id="ips-per-page" name="per_page"
hx-get="/ips" hx-select="#ips-table-wrap" hx-target="#ips-table-wrap" hx-swap="outerHTML"
hx-include="#ips-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#ips-table-wrap:queue last">
{{$pp := .PerPage}}
{{range .PerPageOptions}}<option value="{{.}}" {{if eq . $pp}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</form>
{{template "ips_table_wrap" .}}
{{end}}
{{/* #ips-table-wrap is the swap target of every table mutation, the filter form
and the pager (all hx-swap="outerHTML", so the wrapper's own hx-get always
points at the page/filter it currently shows). It listens for the
scan-finished event (HX-Trigger from the scan progress panel) and reloads
itself. hx-disinherit="*": its attributes must not leak into the buttons
inside. */}}
{{define "ips_table_wrap"}}
<div id="ips-table-wrap" hx-get="{{.SelfURL}}" hx-trigger="scan-finished from:body" hx-select="#ips-table-wrap" hx-target="this" hx-swap="outerHTML" hx-disinherit="*">
{{template "ips_table" .}}
</div>
{{end}}
{{/* Scan progress panel. Lives outside #ips-form (no checkbox payload, survives
table swaps). While the job runs it polls itself; the response that finds
it finished has no hx-trigger and carries HX-Trigger: scan-finished. */}}
{{define "scan_progress"}}
{{$s := .Status}}
<div id="scan-progress" class="panel scan-progress"{{if .Poll}} hx-get="/ips/scan/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
<div class="panel-body">
<div class="scan-actions">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan" hx-target="#scan-progress" hx-swap="outerHTML" hx-params="page,per_page,q,state,result"{{if $s.Running}} disabled{{end}}>Сканировать Floating IP</button>
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan?dry_run=true" hx-target="#scan-progress" hx-swap="outerHTML" hx-params="page,per_page,q,state,result"{{if $s.Running}} disabled{{end}} title="Только найти и посчитать свободные адреса, очередь не меняется">Пробное сканирование</button>
{{if and $s.State (ne $s.State "idle")}}<span class="pill {{$s.PillClass}}">{{$s.StateLabel}}</span>{{if $s.DryRun}} <span class="muted">пробный запуск: очередь не меняется</span>{{end}}{{else}}<span class="muted">Найти все свободные Floating IP проекта и поставить их в очередь.</span>{{end}}
</div>
{{if and $s.State (ne $s.State "idle")}}
{{if or $s.Running (eq $s.State "done")}}
{{if $s.Indeterminate}}<progress aria-label="Сканирование"></progress>
{{else if eq $s.State "done"}}<progress max="1" value="1" aria-label="Сканирование"></progress>
{{else}}<progress max="{{$s.Free}}" value="{{$s.Handled}}" aria-label="Сканирование"></progress>{{end}}
{{end}}
<dl class="scan-counters">
<div><dt>прочитано страниц</dt><dd>{{$s.Pages}}</dd></div>
<div><dt>найдено адресов</dt><dd>{{$s.Discovered}}</dd></div>
<div><dt>свободных</dt><dd>{{$s.Free}}</dd></div>
<div><dt>добавлено</dt><dd>{{$s.Added}}</dd></div>
<div><dt>повторно</dt><dd>{{$s.Requeued}}</dd></div>
<div><dt>переупорядочено</dt><dd>{{$s.Reordered}}</dd></div>
{{if $s.SkippedInProgress}}<div><dt>уже в работе</dt><dd>{{$s.SkippedInProgress}}</dd></div>{{end}}
{{with $s.Elapsed}}<div><dt>время</dt><dd>{{.}}</dd></div>{{end}}
</dl>
{{if $s.Error}}<p class="scan-error">{{$s.Error}}</p>{{end}}
{{end}}
</div>
</div>
{{end}}
{{define "ips_table"}}
<form id="ips-form">
<form id="ips-form" onchange="var f=this,k=f.querySelectorAll('input[name=addresses]:checked').length,n=f.querySelectorAll('input[name=addresses]').length;f.querySelector('[data-sel-count]').textContent=k;f.classList.toggle('sel-all',n>0&&k===n);if(k!==n){f.elements.scope.value='';f.classList.remove('scope-all')}">
<input type="hidden" class="ips-ctx" name="page" value="{{.Page}}">
<input type="hidden" class="ips-ctx" name="per_page" value="{{.PerPage}}">
<input type="hidden" class="ips-ctx" name="q" value="{{.Filter.Q}}">
<input type="hidden" class="ips-ctx" name="state" value="{{.Filter.State}}">
<input type="hidden" class="ips-ctx" name="result" value="{{.Filter.Result}}">
<input type="hidden" name="scope" value="">
<div class="panel">
<div class="panel-body" style="display:flex; gap:8px; align-items:center;">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/scan" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last">Сканировать Floating IP</button>
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last">Перепроверить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ адреса из очереди, включая те, что сейчас проверяются? Действие необратимо.">Очистить всё</button>
<div class="panel-body bulk-bar">
<button type="button" class="btn btn-ghost btn-sm" hx-post="/ips/recheck" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last">Перепроверить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm btn-del-sel" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить выбранные адреса без возможности восстановления?">Удалить выбранные</button>
<button type="button" class="btn btn-danger-ghost btn-sm btn-del-all" hx-post="/ips/delete" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ {{.Total}} {{pluralAddr .Total}}{{if .Filter.Active}} по текущему фильтру{{end}}, включая идущие проверки? Действие необратимо.">Удалить все {{.Total}} по фильтру</button>
<button type="button" class="btn btn-danger-ghost btn-sm" hx-post="/ips/clear" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить ВСЕ {{.QueueTotal}} {{pluralAddr .QueueTotal}} из очереди, включая идущие проверки? Действие необратимо.">Очистить всё</button>
</div>
<div class="bulk-hint">
Выбрано на странице: <b data-sel-count>0</b> из {{len .Items}}
{{if gt .Total (len .Items)}}<a href="#" class="select-all-link" onclick="var f=this.closest('form');f.elements.scope.value='all';f.classList.add('scope-all');return false">Выбрать все {{.Total}} по фильтру</a>
<span class="scope-all-msg">Выбраны все {{.Total}} {{pluralAddr .Total}} по фильтру (не только на этой странице).</span>{{end}}
</div>
<div class="table-scroll">
<table>
<thead><tr><th><input type="checkbox" onclick="this.closest('table').querySelectorAll('input[name=addresses]').forEach(cb => cb.checked = this.checked)"></th><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<thead><tr><th><input type="checkbox" aria-label="Выбрать все на странице" onclick="this.closest('table').querySelectorAll('input[name=addresses]').forEach(cb => cb.checked = this.checked)"></th><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<tbody>
{{range .Items}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt $.FIPSettleSeconds}}
@@ -72,11 +171,11 @@
<td data-label="">
<div class="actions">
{{if $terminal}}
<button class="btn btn-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last">Перепроверить</button>
<button class="btn btn-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last">Перепроверить</button>
{{else}}
<button class="btn btn-danger-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
<button class="btn btn-danger-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
{{end}}
<button class="btn btn-danger-ghost btn-sm" hx-delete="/ips/{{.IPAddress}}" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить {{.IPAddress}} без возможности восстановления?">Удалить</button>
<button class="btn btn-danger-ghost btn-sm" hx-delete="/ips/{{.IPAddress}}" hx-target="#ips-table-wrap" hx-swap="outerHTML" hx-params="page,per_page,q,state,result" hx-sync="#ips-table-wrap:queue last" hx-confirm="Удалить {{.IPAddress}} без возможности восстановления?">Удалить</button>
</div>
</td>
</tr>
@@ -85,6 +184,18 @@
</table>
</div>
</div>
{{template "pager" .Pager}}
</form>
{{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}}
{{if not .Items}}<p class="muted">{{if .Filter.Active}}Ничего не найдено по текущему фильтру.{{else}}Очередь пуста.{{end}}</p>{{end}}
{{end}}
{{define "pager"}}{{if .Total}}
<div class="pager">
<span class="pager-info">Показано {{.From}}–{{.To}} из {{.Total}}</span>
<span class="pager-nav">
{{if .PrevURL}}<a class="btn btn-ghost btn-sm" href="{{.PrevURL}}" hx-get="{{.PrevURL}}" hx-select="#{{.Wrap}}" hx-target="#{{.Wrap}}" hx-swap="outerHTML" hx-push-url="true" rel="prev" aria-label="Предыдущая страница">‹</a>{{else}}<span class="btn btn-ghost btn-sm is-disabled" aria-disabled="true">‹</span>{{end}}
<span class="pager-page">{{.Page}} / {{.Pages}}</span>
{{if .NextURL}}<a class="btn btn-ghost btn-sm" href="{{.NextURL}}" hx-get="{{.NextURL}}" hx-select="#{{.Wrap}}" hx-target="#{{.Wrap}}" hx-swap="outerHTML" hx-push-url="true" rel="next" aria-label="Следующая страница">›</a>{{else}}<span class="btn btn-ghost btn-sm is-disabled" aria-disabled="true">›</span>{{end}}
</span>
</div>
{{end}}{{end}}
+7
View File
@@ -78,6 +78,13 @@
</a>
</nav>
{{if .AuthEnabled}}
<form class="sidebar-user" method="post" action="/logout">
<span class="sidebar-user-name" title="{{.User}}">{{.User}}</span>
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
</form>
{{end}}
<div class="sidebar-foot">
<span class="sidebar-foot-status"><span class="pulse-dot"></span>control-api</span>
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
+29
View File
@@ -0,0 +1,29 @@
{{define "login_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
<div class="bg-grid"></div>
<main class="login-shell">
<div class="login-card">
<form class="panel" method="post" action="/login" autocomplete="on">
<div class="panel-head"><h2>Cloud IP Validator — вход</h2></div>
<div class="panel-body">
{{if .Error}}<div class="alert alert-warning" role="alert">{{.Error}}</div>{{end}}
<input type="hidden" name="next" value="{{.Next}}">
<div class="field">
<label for="login-username">Логин</label>
<input type="text" id="login-username" name="username" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="login-password">Пароль</label>
<input type="password" id="login-password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</div>
</form>
</div>
</main>
</body>
</html>
{{end}}
+2 -2
View File
@@ -50,8 +50,8 @@
</select>
</div>
</div>
<p class="muted" style="padding:0 16px 12px">Действует на обе таблицы ниже. Фильтр по статусу — это фильтр по
итоговому результату, поэтому при выборе конкретного статуса строки «Текущей проверки» (у неё ещё нет результата)
<p class="muted" style="padding:0 16px 12px">Действует на все списки ниже. Фильтр по статусу — это фильтр по
итоговому результату, поэтому при выборе конкретного статуса строки «В работе» и «В очереди» (у них ещё нет результата)
не показываются.</p>
</form>
@@ -6,6 +6,21 @@
<div class="stat-card{{if eq $state "failed"}} bad{{else if eq $state "checking"}} accented{{end}}"><span class="value">{{$count}}</span><span class="label">{{$state}}</span></div>
{{end}}
</div>
{{if .Progress.Total}}
<div class="overall-progress" id="overview-progress">
<p>Готово {{.Progress.Done}} из {{.Progress.Total}} ({{.Progress.Percent}}%) · в работе {{.Progress.Active}} · в очереди {{.Progress.Queued}}{{if .Progress.ETA}} · осталось ≈ {{.Progress.ETA}}{{end}}</p>
<progress max="{{.Progress.Total}}" value="{{.Progress.Done}}" aria-label="Готово"></progress>
</div>
{{end}}
{{with .Status.ResultsByOverall}}{{if or (index . "pass") (index . "partial") (index . "fail") (index . "cancelled")}}
<p class="muted" id="overview-results" style="margin-top:8px">Итоги проверок: pass {{index . "pass"}} · partial {{index . "partial"}} · fail {{index . "fail"}} · cancelled {{index . "cancelled"}}</p>
{{end}}{{end}}
{{with .Scan}}
<p class="muted" id="overview-scan" style="margin-top:8px">Сканирование: {{.StateLabel}}{{if .DryRun}} (пробное){{end}} · прочитано страниц {{.Pages}}, найдено адресов {{.Discovered}}, свободных {{.Free}}{{if .Handled}}, обработано {{.Handled}}{{end}} · <a href="/ips">подробнее</a></p>
{{end}}
{{with .AutoCycle}}{{if .Enabled}}
<p class="muted" id="overview-auto-cycle" style="margin-top:12px"><span class="pill pill-info">Автоцикл активен</span> · {{.PhaseLabel}} · следующий запуск: {{fmtTime .NextRunAt}}</p>
{{end}}{{end}}
{{end}}
{{/* Out-of-band counterpart of overview_stats, appended to every
@@ -17,14 +32,14 @@
{{define "overview_stats_oob"}}<div id="overview-stats" hx-swap-oob="true">{{template "overview_stats" .}}</div>{{end}}
{{define "overview_tables"}}
<h2 class="section-title">Текущая проверка</h2>
{{if .CurrentItems}}
<h2 class="section-title">В работе</h2>
{{if .ActiveItems}}
<div class="panel">
<div class="table-scroll">
<table>
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Назначено</th></tr></thead>
<tbody>
{{range .CurrentItems}}
{{range .ActiveItems}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt 0}}
<tr>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
@@ -38,11 +53,35 @@
</table>
</div>
</div>
{{if gt .ActiveTotal (len .ActiveItems)}}<p class="muted">Показано {{len .ActiveItems}} из {{.ActiveTotal}} · <a href="/ips?state=active{{if .Query}}&amp;q={{.Query}}{{end}}">все в работе</a></p>{{end}}
{{else}}
{{if or .Query .StatusFilter}}<p class="muted">Ничего не найдено по текущему фильтру.</p>
{{else}}<p class="muted">Сейчас нет адресов в обработке.</p>{{end}}
{{end}}
{{if not .StatusFilter}}
<p class="queue-line">В очереди: <b>{{.QueuedTotal}}</b> · <a href="/ips?state=queued{{if .Query}}&amp;q={{.Query}}{{end}}">открыть весь список</a></p>
{{if .QueuedItems}}
<div class="panel">
<div class="table-scroll">
<table class="compact">
<thead><tr><th>Ближайшие в очереди</th><th>Состояние</th></tr></thead>
<tbody>
{{range .QueuedItems}}
{{$b := ipBadge .State .OverallResult .FIPAssociatedAt 0}}
<tr>
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{if gt .QueuedTotal (len .QueuedItems)}}<p class="muted">Показаны ближайшие {{len .QueuedItems}} из {{.QueuedTotal}}.</p>{{end}}
{{end}}
{{end}}
<h2 class="section-title">Последние {{.LastN}} завершённых</h2>
<div class="breakdown">
<span>pass <b>{{index .Breakdown "pass"}}</b></span>
@@ -47,6 +47,17 @@
<option value="cancelled" {{if eq .StatusFilter "cancelled"}}selected{{end}}>cancelled</option>
</select>
</div>
<div class="field" style="flex:0 0 120px">
<label for="registry-per-page">На странице</label>
<select id="registry-per-page" name="per_page"
hx-get="/registry" hx-select="#registry-table-wrap" hx-target="#registry-table-wrap" hx-swap="outerHTML"
hx-include="#registry-filter" hx-trigger="change"
hx-replace-url="true" hx-sync="#registry-table-wrap:queue last">
{{$pp := .PerPage}}
{{range .PerPageOptions}}<option value="{{.}}" {{if eq . $pp}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</form>
@@ -83,6 +94,7 @@
</tbody>
</table>
</div>
{{template "pager" .Pager}}
</div>
{{else if or .Query .StatusFilter}}<p class="muted">Ничего не найдено по текущему фильтру.</p>
{{else}}<p class="muted">Реестр пуст — ни один адрес ещё не ставился на проверку.</p>{{end}}
@@ -28,6 +28,47 @@
{{end}}
{{define "settings_form"}}
<div class="panel" id="auto-cycle-panel">
<div class="panel-body">
<h2 class="section-title" style="margin-top:0">Автоматический цикл</h2>
<p class="muted" style="margin-bottom:16px">Повторяет сценарий оператора: «Очистить всё» → «Сканировать Floating IP» →
ожидание, пока все адреса очереди пройдут проверку → пауза и новый цикл. Интервал отсчитывается от завершения
предыдущего цикла. Пока автоцикл включён, периодическое сканирование по <code>fip_scan_interval_seconds</code>
не выполняется. Выключение не прерывает проверки, которые уже идут.</p>
<p style="margin-bottom:16px">
<span class="pill {{if .AutoCycle.Enabled}}pill-success{{else}}pill-neutral{{end}}">{{if .AutoCycle.Enabled}}Автоцикл включён{{else}}Автоцикл выключен{{end}}</span>
· фаза: {{.AutoCycle.PhaseLabel}}
</p>
<div class="muted" style="margin-bottom:16px">
<div>Последний запуск: {{fmtTime .AutoCycle.LastRunStartedAt}}</div>
<div>Завершён: {{fmtTime .AutoCycle.LastRunFinishedAt}}</div>
<div>Следующий запуск: {{fmtTime .AutoCycle.NextRunAt}}</div>
<div>Результат последнего цикла: <span class="pill {{.AutoCycle.OutcomePillClass}}">{{.AutoCycle.OutcomeLabel}}</span>{{if .AutoCycle.LastError}} — {{.AutoCycle.LastError}}{{end}}</div>
<div>Свободных адресов в последнем скане: {{.AutoCycle.LastScannedFree}} · завершённых циклов: {{.AutoCycle.RunsTotal}}</div>
</div>
<form hx-put="/settings/auto-cycle" hx-target="#settings-form-wrap" hx-swap="innerHTML">
<div class="field-row">
<div class="field">
<label for="auto_cycle_interval">Интервал между циклами (мин)</label>
<input type="number" id="auto_cycle_interval" name="interval_minutes" min="1" step="any" value="{{.AutoCycle.IntervalMinutes}}" required>
</div>
<div class="field">
<label for="auto_cycle_max_run">Максимальная длительность проверки (мин, 0 = без лимита)</label>
<input type="number" id="auto_cycle_max_run" name="max_run_minutes" min="0" step="any" value="{{.AutoCycle.MaxRunMinutes}}" required>
</div>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
<div style="margin-top:16px">
{{if .AutoCycle.Enabled}}
<button type="button" class="btn btn-primary" hx-post="/settings/auto-cycle/stop" hx-target="#settings-form-wrap" hx-swap="innerHTML">Выключить</button>
{{else}}
<button type="button" class="btn btn-primary" hx-post="/settings/auto-cycle/start" hx-target="#settings-form-wrap" hx-swap="innerHTML">Включить</button>
{{end}}
</div>
</div>
</div>
<div class="panel">
<div class="panel-body">
<p class="muted" style="margin-bottom:16px">Пауза между привязкой Floating IP к валидатору и началом self-check —
+8
View File
@@ -34,6 +34,12 @@ var proberHeartbeatSchema string
//go:embed migrations/0007_ip_registry.sql
var ipRegistrySchema string
//go:embed migrations/0008_auto_cycle.sql
var autoCycleSchema string
//go:embed migrations/0009_scale_indexes.sql
var scaleIndexesSchema string
// migrations is the ordered list of schema versions. Each entry's SQL is
// applied, in order, for any version greater than the database's current
// PRAGMA user_version — so a fresh database walks the whole list and an
@@ -49,6 +55,8 @@ var migrations = []struct {
{5, unboundedSitesSchema},
{6, proberHeartbeatSchema},
{7, ipRegistrySchema},
{8, autoCycleSchema},
{9, scaleIndexesSchema},
}
type DB struct {
@@ -0,0 +1,24 @@
-- Automatic check cycle (see docs/USAGE.md): optionally repeats the
-- "clear queue -> scan floating IPs -> wait for all checks to finish ->
-- wait interval" scenario. Singleton row, kept apart from `settings` so that
-- a full-overwrite PUT /config/orchestrator never resets these fields.
-- State is persisted so the cycle survives a control-api restart.
CREATE TABLE auto_cycle (
id INTEGER PRIMARY KEY CHECK (id = 1),
enabled INTEGER NOT NULL DEFAULT 0,
interval_seconds INTEGER NOT NULL DEFAULT 3600,
max_run_seconds INTEGER NOT NULL DEFAULT 0,
phase TEXT NOT NULL DEFAULT 'idle',
run_started_at TIMESTAMP,
next_run_at TIMESTAMP,
last_run_started_at TIMESTAMP,
last_run_finished_at TIMESTAMP,
last_outcome TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
last_scanned_free INTEGER NOT NULL DEFAULT 0,
runs_total INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO auto_cycle (id, enabled, interval_seconds, max_run_seconds, phase) VALUES (1, 0, 3600, 0, 'idle');
@@ -0,0 +1,11 @@
-- Scale indexes (see docs/changes: FIP scan at scale).
--
-- idx_ip_queue_registry: ListRegistry/ListRegistryPage look up the live
-- ip_queue row of every registry address by registry_id; without an index
-- that was a full scan per address, i.e. O(n^2) with thousands of addresses.
--
-- idx_ip_queue_state_aggregated: paged "recently finished" listings
-- (state filter, newest aggregated_at first).
CREATE INDEX idx_ip_queue_registry ON ip_queue(registry_id);
CREATE INDEX idx_ip_queue_state_aggregated ON ip_queue(state, aggregated_at);
+75
View File
@@ -39,6 +39,32 @@ const (
SourceEgress = "egress"
)
// IPStates lists every valid ip_queue state, in lifecycle order.
var IPStates = []string{
IPQueued, IPAssigningFIP, IPAwaitingSelfCheck, IPChecking, IPAggregating,
IPDone, IPFailed, IPOccupied,
}
// IsValidIPState reports whether s is one of IPStates.
func IsValidIPState(s string) bool {
for _, v := range IPStates {
if v == s {
return true
}
}
return false
}
// IsValidResult reports whether s is a valid overall result value
// (pass | partial | fail | cancelled).
func IsValidResult(s string) bool {
switch s {
case ResultPass, ResultPartial, ResultFail, ResultCancelled:
return true
}
return false
}
// InboundSource returns the checks.source value for the given prober site
// index (1-based), e.g. InboundSource(1) == "inbound-site-1".
func InboundSource(siteIndex int) string {
@@ -226,3 +252,52 @@ type InboundChecksSettings struct {
CreatedAt time.Time
UpdatedAt time.Time
}
// Auto-cycle phases and outcomes (see AutoCycle).
const (
AutoCyclePhaseIdle = "idle"
AutoCyclePhaseRunning = "running"
AutoCyclePhaseWaiting = "waiting"
// AutoCyclePhaseScanning: the queue is being cleared and the floating IPs
// are being (re)discovered and enqueued by the background scan job.
AutoCyclePhaseScanning = "scanning"
AutoCycleOutcomeCompleted = "completed"
AutoCycleOutcomeNoFreeIPs = "no_free_ips"
AutoCycleOutcomeTimeout = "timeout"
AutoCycleOutcomeError = "error"
AutoCycleOutcomeStopped = "stopped"
)
// AutoCycle is the singleton row describing the automatic check cycle:
// its configuration (Enabled, IntervalSeconds, MaxRunSeconds) and its
// persisted runtime state (Phase and timestamps). MaxRunSeconds == 0 means
// no limit on how long a run may wait for checks to finish.
type AutoCycle struct {
Enabled bool
IntervalSeconds int
MaxRunSeconds int
Phase string
RunStartedAt *time.Time
NextRunAt *time.Time
LastRunStartedAt *time.Time
LastRunFinishedAt *time.Time
LastOutcome string
LastError string
LastScannedFree int
RunsTotal int
}
// AutoCycleState is a full replacement of the runtime-state columns of the
// auto_cycle row (everything except configuration and enabled flag).
type AutoCycleState struct {
Phase string
RunStartedAt *time.Time
NextRunAt *time.Time
LastRunStartedAt *time.Time
LastRunFinishedAt *time.Time
LastOutcome string
LastError string
LastScannedFree int
RunsTotal int
}
+121
View File
@@ -0,0 +1,121 @@
package db
import (
"context"
"database/sql"
"fmt"
"time"
)
// MinAutoCycleIntervalSeconds is the smallest allowed pause between
// automatic cycles; it protects the OpenStack API from overly frequent
// floating-IP scans.
const MinAutoCycleIntervalSeconds = 60
// GetAutoCycle returns the singleton auto_cycle row. Migration 0008 inserts
// it, so sql.ErrNoRows would indicate a broken database, not a normal case.
func (d *DB) GetAutoCycle(ctx context.Context) (AutoCycle, error) {
var a AutoCycle
var enabled int
var runStarted, nextRun, lastStarted, lastFinished sql.NullString
err := d.QueryRowContext(ctx, `
SELECT enabled, interval_seconds, max_run_seconds, phase,
run_started_at, next_run_at, last_run_started_at, last_run_finished_at,
last_outcome, last_error, last_scanned_free, runs_total
FROM auto_cycle WHERE id=1
`).Scan(&enabled, &a.IntervalSeconds, &a.MaxRunSeconds, &a.Phase,
&runStarted, &nextRun, &lastStarted, &lastFinished,
&a.LastOutcome, &a.LastError, &a.LastScannedFree, &a.RunsTotal)
if err != nil {
return AutoCycle{}, err
}
a.Enabled = enabled != 0
if a.RunStartedAt, err = nullStringToTimePtr(runStarted); err != nil {
return AutoCycle{}, err
}
if a.NextRunAt, err = nullStringToTimePtr(nextRun); err != nil {
return AutoCycle{}, err
}
if a.LastRunStartedAt, err = nullStringToTimePtr(lastStarted); err != nil {
return AutoCycle{}, err
}
if a.LastRunFinishedAt, err = nullStringToTimePtr(lastFinished); err != nil {
return AutoCycle{}, err
}
return a, nil
}
// SetAutoCycleParams updates the auto-cycle configuration. A nil argument
// leaves the corresponding field unchanged (partial update). Validation:
// interval >= MinAutoCycleIntervalSeconds, maxRun >= 0 (0 = no limit).
func (d *DB) SetAutoCycleParams(ctx context.Context, intervalSeconds, maxRunSeconds *int) error {
if intervalSeconds != nil && *intervalSeconds < MinAutoCycleIntervalSeconds {
return fmt.Errorf("interval_seconds must be >= %d: %w", MinAutoCycleIntervalSeconds, ErrValidation)
}
if maxRunSeconds != nil && *maxRunSeconds < 0 {
return fmt.Errorf("max_run_seconds must be >= 0: %w", ErrValidation)
}
now := timeToDB(Now())
if intervalSeconds != nil {
if _, err := d.ExecContext(ctx, `
UPDATE auto_cycle SET interval_seconds=?, updated_at=? WHERE id=1
`, *intervalSeconds, now); err != nil {
return err
}
}
if maxRunSeconds != nil {
if _, err := d.ExecContext(ctx, `
UPDATE auto_cycle SET max_run_seconds=?, updated_at=? WHERE id=1
`, *maxRunSeconds, now); err != nil {
return err
}
}
return nil
}
// SetAutoCycleEnabled flips the enabled flag and resets the runtime phase to
// idle (clearing run_started_at). nextRunAt sets next_run_at (nil clears it).
// A non-empty outcome is stored as last_outcome (and last_error is cleared);
// an empty one leaves the last outcome untouched.
func (d *DB) SetAutoCycleEnabled(ctx context.Context, enabled bool, nextRunAt *time.Time, outcome string) error {
en := 0
if enabled {
en = 1
}
now := timeToDB(Now())
_, err := d.ExecContext(ctx, `
UPDATE auto_cycle SET
enabled=?,
phase=?,
run_started_at=NULL,
next_run_at=?,
last_outcome=CASE WHEN ?='' THEN last_outcome ELSE ? END,
last_error=CASE WHEN ?='' THEN last_error ELSE '' END,
updated_at=?
WHERE id=1
`, en, AutoCyclePhaseIdle, timePtrToDB(nextRunAt), outcome, outcome, outcome, now)
return err
}
// UpdateAutoCycleState replaces all runtime-state columns (phase,
// timestamps, last outcome/error, counters) in one statement. It does not
// touch the configuration or the enabled flag.
func (d *DB) UpdateAutoCycleState(ctx context.Context, s AutoCycleState) error {
switch s.Phase {
case AutoCyclePhaseIdle, AutoCyclePhaseScanning, AutoCyclePhaseRunning, AutoCyclePhaseWaiting:
default:
return fmt.Errorf("invalid auto-cycle phase %q: %w", s.Phase, ErrValidation)
}
now := timeToDB(Now())
_, err := d.ExecContext(ctx, `
UPDATE auto_cycle SET
phase=?, run_started_at=?, next_run_at=?,
last_run_started_at=?, last_run_finished_at=?,
last_outcome=?, last_error=?, last_scanned_free=?, runs_total=?,
updated_at=?
WHERE id=1
`, s.Phase, timePtrToDB(s.RunStartedAt), timePtrToDB(s.NextRunAt),
timePtrToDB(s.LastRunStartedAt), timePtrToDB(s.LastRunFinishedAt),
s.LastOutcome, s.LastError, s.LastScannedFree, s.RunsTotal, now)
return err
}
+156
View File
@@ -0,0 +1,156 @@
package db
import (
"errors"
"testing"
"time"
)
func TestAutoCycleDefaultsFromMigration(t *testing.T) {
d, ctx := newTestDB(t)
a, err := d.GetAutoCycle(ctx)
if err != nil {
t.Fatalf("get auto cycle: %v", err)
}
if a.Enabled {
t.Fatalf("expected disabled by default")
}
if a.IntervalSeconds != 3600 {
t.Fatalf("expected default interval 3600, got %d", a.IntervalSeconds)
}
if a.MaxRunSeconds != 0 {
t.Fatalf("expected default max_run_seconds 0, got %d", a.MaxRunSeconds)
}
if a.Phase != AutoCyclePhaseIdle {
t.Fatalf("expected phase idle, got %q", a.Phase)
}
if a.RunStartedAt != nil || a.NextRunAt != nil || a.LastRunStartedAt != nil || a.LastRunFinishedAt != nil {
t.Fatalf("expected all timestamps unset, got %+v", a)
}
if a.LastOutcome != "" || a.LastError != "" || a.LastScannedFree != 0 || a.RunsTotal != 0 {
t.Fatalf("expected empty last-run info, got %+v", a)
}
}
func TestAutoCycleParamsRoundTripAndPartialUpdate(t *testing.T) {
d, ctx := newTestDB(t)
interval, maxRun := 120, 900
if err := d.SetAutoCycleParams(ctx, &interval, &maxRun); err != nil {
t.Fatalf("set params: %v", err)
}
a, err := d.GetAutoCycle(ctx)
if err != nil {
t.Fatalf("get: %v", err)
}
if a.IntervalSeconds != 120 || a.MaxRunSeconds != 900 {
t.Fatalf("expected 120/900, got %d/%d", a.IntervalSeconds, a.MaxRunSeconds)
}
// Partial: only max_run_seconds changes.
newMax := 0
if err := d.SetAutoCycleParams(ctx, nil, &newMax); err != nil {
t.Fatalf("partial set: %v", err)
}
a, _ = d.GetAutoCycle(ctx)
if a.IntervalSeconds != 120 || a.MaxRunSeconds != 0 {
t.Fatalf("expected 120/0 after partial update, got %d/%d", a.IntervalSeconds, a.MaxRunSeconds)
}
// Partial: only interval changes.
newInterval := 60
if err := d.SetAutoCycleParams(ctx, &newInterval, nil); err != nil {
t.Fatalf("partial set interval: %v", err)
}
a, _ = d.GetAutoCycle(ctx)
if a.IntervalSeconds != 60 || a.MaxRunSeconds != 0 {
t.Fatalf("expected 60/0, got %d/%d", a.IntervalSeconds, a.MaxRunSeconds)
}
}
func TestAutoCycleParamsValidation(t *testing.T) {
d, ctx := newTestDB(t)
low := 59
if err := d.SetAutoCycleParams(ctx, &low, nil); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for interval 59, got %v", err)
}
neg := -1
if err := d.SetAutoCycleParams(ctx, nil, &neg); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for negative max_run_seconds, got %v", err)
}
// A rejected request must not partially apply the valid half.
ok := 300
if err := d.SetAutoCycleParams(ctx, &ok, &neg); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation, got %v", err)
}
a, _ := d.GetAutoCycle(ctx)
if a.IntervalSeconds != 3600 {
t.Fatalf("interval must stay 3600 after rejected update, got %d", a.IntervalSeconds)
}
}
func TestAutoCycleEnabledAndStateRoundTrip(t *testing.T) {
d, ctx := newTestDB(t)
next := Now()
if err := d.SetAutoCycleEnabled(ctx, true, &next, ""); err != nil {
t.Fatalf("enable: %v", err)
}
a, _ := d.GetAutoCycle(ctx)
if !a.Enabled || a.Phase != AutoCyclePhaseIdle {
t.Fatalf("expected enabled+idle, got %+v", a)
}
if a.NextRunAt == nil || !a.NextRunAt.Equal(next) {
t.Fatalf("expected next_run_at=%v, got %v", next, a.NextRunAt)
}
started := next.Add(time.Second)
finished := next.Add(time.Minute)
nextRun := finished.Add(time.Hour)
st := AutoCycleState{
Phase: AutoCyclePhaseWaiting,
NextRunAt: &nextRun,
LastRunStartedAt: &started,
LastRunFinishedAt: &finished,
LastOutcome: AutoCycleOutcomeCompleted,
LastError: "boom",
LastScannedFree: 3,
RunsTotal: 7,
}
if err := d.UpdateAutoCycleState(ctx, st); err != nil {
t.Fatalf("update state: %v", err)
}
a, _ = d.GetAutoCycle(ctx)
if !a.Enabled {
t.Fatalf("UpdateAutoCycleState must not touch the enabled flag")
}
if a.Phase != AutoCyclePhaseWaiting || a.LastOutcome != AutoCycleOutcomeCompleted ||
a.LastError != "boom" || a.LastScannedFree != 3 || a.RunsTotal != 7 {
t.Fatalf("state mismatch: %+v", a)
}
if a.RunStartedAt != nil {
t.Fatalf("expected run_started_at nil, got %v", a.RunStartedAt)
}
if a.NextRunAt == nil || !a.NextRunAt.Equal(nextRun) ||
a.LastRunStartedAt == nil || !a.LastRunStartedAt.Equal(started) ||
a.LastRunFinishedAt == nil || !a.LastRunFinishedAt.Equal(finished) {
t.Fatalf("timestamp mismatch: %+v", a)
}
// Disable with an outcome: phase back to idle, outcome recorded,
// last_error cleared, counters preserved.
if err := d.SetAutoCycleEnabled(ctx, false, nil, AutoCycleOutcomeStopped); err != nil {
t.Fatalf("disable: %v", err)
}
a, _ = d.GetAutoCycle(ctx)
if a.Enabled || a.Phase != AutoCyclePhaseIdle || a.LastOutcome != AutoCycleOutcomeStopped ||
a.LastError != "" || a.NextRunAt != nil || a.RunsTotal != 7 {
t.Fatalf("unexpected state after disable: %+v", a)
}
if err := d.UpdateAutoCycleState(ctx, AutoCycleState{Phase: "bogus"}); !errors.Is(err, ErrValidation) {
t.Fatalf("expected ErrValidation for bogus phase, got %v", err)
}
}
+323 -20
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
@@ -100,7 +101,7 @@ func (d *DB) ClaimNextQueued(ctx context.Context, validatorID string, leaseTTL t
res, err = tx.ExecContext(ctx, `
UPDATE validators SET state=?, current_ip_id=?, updated_at=?
WHERE validator_id=? AND state=?
WHERE validator_id=? AND state=? AND current_ip_id IS NULL
`, ValidatorAssigned, item.ID, timeToDB(now), validatorID, ValidatorIdle)
if err != nil {
return nil, err
@@ -121,13 +122,59 @@ func (d *DB) ClaimNextQueued(ctx context.Context, validatorID string, leaseTTL t
return &item, nil
}
// SetFIPAssociated records that the floating IP is now attached. It only
// applies to a row still in assigning_fip: if the address was deleted or
// cancelled while the cloud call was in flight, it returns ErrInvalidState
// and the caller must detach the floating IP again.
func (d *DB) SetFIPAssociated(ctx context.Context, ipID int64, fipID string, leaseTTL time.Duration) error {
now := Now()
_, err := d.ExecContext(ctx, `
res, err := d.ExecContext(ctx, `
UPDATE ip_queue SET state=?, fip_id=?, fip_associated_at=?, lease_expires_at=?, updated_at=?
WHERE id=?
`, IPAwaitingSelfCheck, fipID, timeToDB(now), timeToDB(now.Add(leaseTTL)), timeToDB(now), ipID)
return err
WHERE id=? AND state=?
`, IPAwaitingSelfCheck, fipID, timeToDB(now), timeToDB(now.Add(leaseTTL)), timeToDB(now), ipID, IPAssigningFIP)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("ip_id %d is no longer assigning_fip: %w", ipID, ErrInvalidState)
}
return nil
}
// KnownAddresses returns the subset of addresses present in ip_registry,
// i.e. addresses this system has ever queued.
func (d *DB) KnownAddresses(ctx context.Context, addresses []string) (map[string]bool, error) {
const chunk = 500
known := make(map[string]bool, len(addresses))
for start := 0; start < len(addresses); start += chunk {
end := start + chunk
if end > len(addresses) {
end = len(addresses)
}
part := addresses[start:end]
args := make([]any, len(part))
for i, a := range part {
args[i] = a
}
rows, err := d.QueryContext(ctx, `SELECT ip_address FROM ip_registry WHERE ip_address IN (`+placeholders(len(part))+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
rows.Close()
return nil, err
}
known[a] = true
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
}
return known, nil
}
func (d *DB) SetChecking(ctx context.Context, ipID int64, leaseTTL time.Duration) error {
@@ -160,7 +207,8 @@ func (d *DB) FinishIP(ctx context.Context, ipID int64, result string) error {
}
// ReleaseFIP records that the floating IP has been disassociated and frees
// the owning validator back to idle, in one transaction.
// the owning validator (if this address is still its current one, see
// freeValidatorSQL), in one transaction.
func (d *DB) ReleaseFIP(ctx context.Context, ipID int64, validatorID string) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
@@ -172,10 +220,7 @@ func (d *DB) ReleaseFIP(ctx context.Context, ipID int64, validatorID string) err
if _, err := tx.ExecContext(ctx, `UPDATE ip_queue SET fip_released_at=?, updated_at=? WHERE id=?`, now, now, ipID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE validators SET state=?, current_ip_id=NULL, updated_at=?
WHERE validator_id=?
`, ValidatorIdle, now, validatorID); err != nil {
if _, err := tx.ExecContext(ctx, freeValidatorSQL, now, validatorID, ipID); err != nil {
return err
}
return tx.Commit()
@@ -205,10 +250,7 @@ func (d *DB) MarkFIPOccupied(ctx context.Context, ipID int64, validatorID string
return err
}
if validatorID != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE validators SET state=?, current_ip_id=NULL, updated_at=?
WHERE validator_id=?
`, ValidatorIdle, now, validatorID); err != nil {
if _, err := tx.ExecContext(ctx, freeValidatorSQL, now, validatorID, ipID); err != nil {
return err
}
}
@@ -268,10 +310,7 @@ func (d *DB) RequeueOrFail(ctx context.Context, ipID int64, validatorID string,
}
if validatorID != "" {
if _, err := tx.ExecContext(ctx, `
UPDATE validators SET state=?, current_ip_id=NULL, updated_at=?
WHERE validator_id=?
`, ValidatorIdle, now, validatorID); err != nil {
if _, err := tx.ExecContext(ctx, freeValidatorSQL, now, validatorID, ipID); err != nil {
return err
}
}
@@ -473,9 +512,11 @@ func (d *DB) DeleteIPs(ctx context.Context, addresses []string) (DeleteIPsResult
func deleteIPTx(ctx context.Context, tx *sql.Tx, ipID int64) error {
now := timeToDB(Now())
if _, err := tx.ExecContext(ctx, `
UPDATE validators SET state=?, current_ip_id=NULL, updated_at=?
UPDATE validators SET current_ip_id=NULL,
state = CASE WHEN state=? THEN state ELSE ? END,
updated_at=?
WHERE current_ip_id=?
`, ValidatorIdle, now, ipID); err != nil {
`, ValidatorUnreachable, ValidatorIdle, now, ipID); err != nil {
return fmt.Errorf("free owning validator: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE checks SET ip_id=NULL WHERE ip_id=?`, ipID); err != nil {
@@ -497,6 +538,268 @@ func deleteIPTx(ctx context.Context, tx *sql.Tx, ipID int64) error {
return nil
}
// ClearAllIPs deletes every ip_queue row in one short, set-based
// transaction (five statements regardless of the queue size) and returns the
// deleted addresses in queue order. It does exactly what deleteIPTx does per
// row: frees every validator that still points at a doomed row, detaches
// checks/events (their registry_id keeps the history), drops the ephemeral
// ip_site_checks progress flags and finally the rows themselves.
// Disassociating attached floating IPs is the caller's (orchestrator's) job.
func (d *DB) ClearAllIPs(ctx context.Context) ([]string, error) {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.QueryContext(ctx, `SELECT ip_address FROM ip_queue ORDER BY sequence`)
if err != nil {
return nil, err
}
deleted := []string{}
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err != nil {
rows.Close()
return nil, err
}
deleted = append(deleted, addr)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
now := timeToDB(Now())
if _, err := tx.ExecContext(ctx, `
UPDATE validators SET current_ip_id=NULL,
state = CASE WHEN state=? THEN state ELSE ? END,
updated_at=?
WHERE current_ip_id IS NOT NULL
`, ValidatorUnreachable, ValidatorIdle, now); err != nil {
return nil, fmt.Errorf("free owning validators: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE checks SET ip_id=NULL WHERE ip_id IS NOT NULL`); err != nil {
return nil, fmt.Errorf("detach checks: %w", err)
}
if _, err := tx.ExecContext(ctx, `UPDATE events SET ip_id=NULL WHERE ip_id IS NOT NULL`); err != nil {
return nil, fmt.Errorf("detach events: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_site_checks`); err != nil {
return nil, fmt.Errorf("delete ip_site_checks: %w", err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM ip_queue`); err != nil {
return nil, fmt.Errorf("delete ip_queue rows: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, err
}
return deleted, nil
}
// FIPRef identifies a queue row that currently holds a Neutron floating-IP
// association.
type FIPRef struct {
IPID int64
IPAddress string
FIPID string
}
// ListFIPRefs returns the queue rows that may still hold a floating IP: an
// fip_id is set and the row is not finished. A finished row (done, failed,
// occupied) keeps its fip_id for display, but its floating IP was already
// disassociated before the final state was written, so listing it would only
// make a bulk clear issue thousands of pointless cloud calls. At most one row
// per validator qualifies, so a clear does not need to load the whole queue.
func (d *DB) ListFIPRefs(ctx context.Context) ([]FIPRef, error) {
rows, err := d.QueryContext(ctx, `SELECT id, ip_address, fip_id FROM ip_queue WHERE fip_id<>'' AND state NOT IN (?, ?, ?) ORDER BY id`,
IPDone, IPFailed, IPOccupied)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FIPRef
for rows.Next() {
var r FIPRef
if err := rows.Scan(&r.IPID, &r.IPAddress, &r.FIPID); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// ListFIPRefsByAddresses is ListFIPRefs restricted to the given addresses
// (unknown addresses and rows that hold no floating IP are simply absent), using a handful of IN (...) queries instead of one lookup per
// address.
func (d *DB) ListFIPRefsByAddresses(ctx context.Context, addresses []string) ([]FIPRef, error) {
const chunk = 500
var out []FIPRef
for start := 0; start < len(addresses); start += chunk {
end := start + chunk
if end > len(addresses) {
end = len(addresses)
}
part := addresses[start:end]
args := []any{IPDone, IPFailed, IPOccupied}
for _, a := range part {
args = append(args, a)
}
rows, err := d.QueryContext(ctx,
`SELECT id, ip_address, fip_id FROM ip_queue WHERE fip_id<>'' AND state NOT IN (?, ?, ?) AND ip_address IN (`+placeholders(len(part))+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var r FIPRef
if err := rows.Scan(&r.IPID, &r.IPAddress, &r.FIPID); err != nil {
rows.Close()
return nil, err
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
}
return out, nil
}
func placeholders(n int) string {
if n <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// CountIPsByState returns how many ip_queue rows are in each state, plus the
// grand total, via a single GROUP BY (no row loading).
func (d *DB) CountIPsByState(ctx context.Context) (map[string]int, int, error) {
rows, err := d.QueryContext(ctx, `SELECT state, COUNT(*) FROM ip_queue GROUP BY state`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
counts := map[string]int{}
total := 0
for rows.Next() {
var state string
var n int
if err := rows.Scan(&state, &n); err != nil {
return nil, 0, err
}
counts[state] = n
total += n
}
return counts, total, rows.Err()
}
// CountIPsByResult returns how many ip_queue rows carry each non-empty
// overall_result (pass/partial/fail/cancelled), via a single GROUP BY.
func (d *DB) CountIPsByResult(ctx context.Context) (map[string]int, error) {
rows, err := d.QueryContext(ctx, `
SELECT overall_result, COUNT(*) FROM ip_queue WHERE overall_result<>'' GROUP BY overall_result
`)
if err != nil {
return nil, err
}
defer rows.Close()
counts := map[string]int{}
for rows.Next() {
var res string
var n int
if err := rows.Scan(&res, &n); err != nil {
return nil, err
}
counts[res] = n
}
return counts, rows.Err()
}
// AnyNonTerminalIP reports whether any ip_queue row is still unfinished (its
// state is none of done/failed/occupied).
func (d *DB) AnyNonTerminalIP(ctx context.Context) (bool, error) {
var any bool
err := d.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM ip_queue WHERE state NOT IN (?, ?, ?))`,
IPDone, IPFailed, IPOccupied).Scan(&any)
return any, err
}
// Order values for IPFilter.Order.
const (
IPOrderSequence = "sequence"
IPOrderAggregatedAtDesc = "aggregated_at_desc"
)
// IPFilter narrows ListIPsPage. The zero value matches everything, ordered by
// queue sequence.
type IPFilter struct {
States []string // any of these states (empty = all)
Query string // substring of ip_address
Result string // overall_result equals this (pass|partial|fail|cancelled)
Order string // IPOrderSequence (default) | IPOrderAggregatedAtDesc
}
// ListIPsPage returns one page (limit/offset) of ip_queue rows matching f,
// plus the total number of matching rows. limit <= 0 means no limit.
func (d *DB) ListIPsPage(ctx context.Context, f IPFilter, limit, offset int) ([]IPQueueItem, int, error) {
var conds []string
var args []any
if len(f.States) > 0 {
conds = append(conds, "state IN ("+placeholders(len(f.States))+")")
for _, s := range f.States {
args = append(args, s)
}
}
if f.Query != "" {
conds = append(conds, "instr(ip_address, ?) > 0")
args = append(args, f.Query)
}
if f.Result != "" {
conds = append(conds, "overall_result = ?")
args = append(args, f.Result)
}
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ") + " "
}
var total int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM ip_queue `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
order := "ORDER BY sequence"
if f.Order == IPOrderAggregatedAtDesc {
// Timestamps are stored as RFC3339Nano, which trims trailing zeros
// and so does not sort correctly as text; strftime normalizes them to
// a fixed millisecond layout. NULL aggregated_at sorts last in DESC.
order = "ORDER BY strftime('%Y-%m-%d %H:%M:%f', aggregated_at) DESC, sequence"
}
q := ipQueueSelect + where + order
qargs := append([]any(nil), args...)
if limit > 0 {
q += " LIMIT ? OFFSET ?"
qargs = append(qargs, limit, offset)
}
rows, err := d.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanIPQueueItems(rows)
if err != nil {
return nil, 0, err
}
if items == nil {
items = []IPQueueItem{}
}
return items, total, nil
}
func (d *DB) SetEgressComplete(ctx context.Context, ipID int64) error {
_, err := d.ExecContext(ctx, `UPDATE ip_queue SET egress_complete=1, updated_at=? WHERE id=?`, timeToDB(Now()), ipID)
return err
+80 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
@@ -66,7 +67,7 @@ type RegistrySummary struct {
func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
rows, err := d.QueryContext(ctx, `
SELECT id, ip_address, first_seen_at, last_seen_at, next_cycle, created_at, updated_at
FROM ip_registry ORDER BY first_seen_at
FROM ip_registry ORDER BY first_seen_at, id
`)
if err != nil {
return nil, err
@@ -89,6 +90,84 @@ func (d *DB) ListRegistry(ctx context.Context) ([]RegistrySummary, error) {
return out, nil
}
// RegistryFilter narrows ListRegistryPage. The zero value matches everything.
type RegistryFilter struct {
Query string // substring of ip_address
LastResult string // pass|partial|fail|cancelled — same meaning as RegistrySummary.LastResult
}
// lastResultCond is the SQL form of fillRegistrySummary's LastResult rule,
// over `ip_registry r LEFT JOIN ip_queue q ON q.registry_id=r.id`: a live
// queue row's overall_result is authoritative (and a live row without one has
// no verdict); with no live row the latest recorded cycle is classified from
// its checks (pass if all succeeded, fail if none, partial otherwise).
const lastResultCond = `(
(q.id IS NOT NULL AND q.overall_result = ?)
OR (q.id IS NULL AND (
SELECT CASE WHEN SUM(c.success) = 0 THEN 'fail'
WHEN SUM(c.success) = COUNT(*) THEN 'pass'
ELSE 'partial' END
FROM checks c
WHERE c.registry_id = r.id
AND c.cycle_id = (SELECT MAX(c2.cycle_id) FROM checks c2 WHERE c2.registry_id = r.id)
HAVING COUNT(*) > 0
) = ?)
)`
// ListRegistryPage returns one page (limit/offset) of the registry in the same
// order as ListRegistry, filtered by f, plus the total number of matching
// rows. LIMIT/OFFSET are applied in SQL before the per-row summary queries,
// so only the rows of the page pay for them. limit <= 0 means no limit.
func (d *DB) ListRegistryPage(ctx context.Context, f RegistryFilter, limit, offset int) ([]RegistrySummary, int, error) {
var conds []string
var args []any
if f.Query != "" {
conds = append(conds, "instr(r.ip_address, ?) > 0")
args = append(args, f.Query)
}
if f.LastResult != "" {
conds = append(conds, lastResultCond)
args = append(args, f.LastResult, f.LastResult)
}
from := ` FROM ip_registry r LEFT JOIN ip_queue q ON q.registry_id = r.id `
where := ""
if len(conds) > 0 {
where = "WHERE " + strings.Join(conds, " AND ") + " "
}
var total int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*)`+from+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT r.id, r.ip_address, r.first_seen_at, r.last_seen_at, r.next_cycle, r.created_at, r.updated_at` +
from + where + `ORDER BY r.first_seen_at, r.id`
qargs := append([]any(nil), args...)
if limit > 0 {
q += " LIMIT ? OFFSET ?"
qargs = append(qargs, limit, offset)
}
rows, err := d.QueryContext(ctx, q, qargs...)
if err != nil {
return nil, 0, err
}
items, err := scanRegistryItems(rows)
rows.Close()
if err != nil {
return nil, 0, err
}
out := make([]RegistrySummary, len(items))
for i, item := range items {
s := RegistrySummary{RegistryItem: item}
if err := d.fillRegistrySummary(ctx, &s); err != nil {
return nil, 0, err
}
out[i] = s
}
return out, total, nil
}
// GetRegistryByAddress returns the registry row (with summary) for a single
// address, or ErrNotFound if it has never been submitted.
func (d *DB) GetRegistryByAddress(ctx context.Context, address string) (*RegistrySummary, error) {
+405
View File
@@ -0,0 +1,405 @@
package db
import (
"context"
"fmt"
"reflect"
"sort"
"testing"
"time"
)
// scaleAddrs returns n distinct addresses 10.<a>.<b>.<c> in ascending order.
func scaleAddrs(n int) []string {
out := make([]string, n)
for i := 0; i < n; i++ {
out[i] = fmt.Sprintf("10.%d.%d.%d", (i/65536)%256, (i/256)%256, i%256)
}
return out
}
// finishWithChecks submits the address, records ok successes and bad
// failures as the current cycle's checks and, when finish != "", finishes the
// row with that overall result.
func finishWithChecks(t *testing.T, d *DB, addr string, ok, bad int, finish string) {
t.Helper()
ctx := context.Background()
ip, err := d.GetIPByAddress(ctx, addr)
if err != nil {
t.Fatalf("get %s: %v", addr, err)
}
for i := 0; i < ok+bad; i++ {
if err := d.UpsertCheck(ctx, Check{
IPID: ip.ID, IPAddress: addr, AttemptNumber: ip.AttemptNumber,
Source: SourceEgress, CheckType: "https", Target: fmt.Sprintf("https://t%d.test", i),
Success: i < ok, CheckedAt: Now(),
}); err != nil {
t.Fatalf("upsert check: %v", err)
}
}
if finish != "" {
if err := d.FinishIP(ctx, ip.ID, finish); err != nil {
t.Fatalf("finish: %v", err)
}
}
}
func TestListIPsPageFiltersTotalOrder(t *testing.T) {
d, ctx := newTestDB(t)
addrs := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.1.1", "10.0.1.2", "192.168.0.10"}
if _, err := d.SubmitIPs(ctx, addrs); err != nil {
t.Fatalf("submit: %v", err)
}
// 10.0.0.1 pass, 10.0.0.2 fail, 10.0.0.3 checking, 10.0.1.1 occupied.
finishWithChecks(t, d, "10.0.0.1", 1, 0, ResultPass)
time.Sleep(3 * time.Millisecond)
finishWithChecks(t, d, "10.0.0.2", 0, 1, ResultFail)
ip3, _ := d.GetIPByAddress(ctx, "10.0.0.3")
if err := d.SetChecking(ctx, ip3.ID, time.Minute); err != nil {
t.Fatal(err)
}
ip4, _ := d.GetIPByAddress(ctx, "10.0.1.1")
if err := d.MarkFIPOccupied(ctx, ip4.ID, ""); err != nil {
t.Fatal(err)
}
addrsOf := func(items []IPQueueItem) []string {
out := []string{}
for _, it := range items {
out = append(out, it.IPAddress)
}
return out
}
items, total, err := d.ListIPsPage(ctx, IPFilter{}, 4, 0)
if err != nil || total != 6 || !reflect.DeepEqual(addrsOf(items), addrs[:4]) {
t.Fatalf("page 1: total=%d items=%v err=%v", total, addrsOf(items), err)
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{}, 4, 4)
if total != 6 || !reflect.DeepEqual(addrsOf(items), addrs[4:]) {
t.Fatalf("page 2: total=%d items=%v", total, addrsOf(items))
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{}, 4, 100)
if total != 6 || len(items) != 0 || items == nil {
t.Fatalf("offset past end: total=%d items=%v", total, items)
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{States: []string{IPDone, IPFailed}}, 50, 0)
if total != 2 || !reflect.DeepEqual(addrsOf(items), []string{"10.0.0.1", "10.0.0.2"}) {
t.Fatalf("states filter: total=%d items=%v", total, addrsOf(items))
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{States: []string{IPQueued}, Query: "10.0.1."}, 50, 0)
if total != 1 || addrsOf(items)[0] != "10.0.1.2" {
t.Fatalf("state+q filter: total=%d items=%v", total, addrsOf(items))
}
items, total, _ = d.ListIPsPage(ctx, IPFilter{Result: ResultFail}, 50, 0)
if total != 1 || addrsOf(items)[0] != "10.0.0.2" {
t.Fatalf("result filter: total=%d items=%v", total, addrsOf(items))
}
// q is a plain substring, not a LIKE pattern: % and _ match literally.
if _, total, _ = d.ListIPsPage(ctx, IPFilter{Query: "%"}, 50, 0); total != 0 {
t.Fatalf("expected literal substring match, total=%d", total)
}
// Newest aggregated first; never-aggregated rows last.
items, _, _ = d.ListIPsPage(ctx, IPFilter{Order: IPOrderAggregatedAtDesc}, 50, 0)
got := addrsOf(items)
if got[0] != "10.0.1.1" && got[0] != "10.0.0.2" {
t.Fatalf("expected a finished row first, got %v", got)
}
if items[0].AggregatedAt == nil || items[len(items)-1].AggregatedAt != nil {
t.Fatalf("expected aggregated rows first and unaggregated last: %v", got)
}
for i := 1; i < len(items); i++ {
a, b := items[i-1].AggregatedAt, items[i].AggregatedAt
if a != nil && b != nil && a.Before(*b) {
t.Fatalf("not sorted by aggregated_at desc at %d: %v", i, got)
}
}
}
func TestCountAndNonTerminal(t *testing.T) {
d, ctx := newTestDB(t)
byState, total, err := d.CountIPsByState(ctx)
if err != nil || total != 0 || len(byState) != 0 {
t.Fatalf("empty: %v %d %v", byState, total, err)
}
if any, err := d.AnyNonTerminalIP(ctx); err != nil || any {
t.Fatalf("empty queue must not report non-terminal: %v %v", any, err)
}
if _, err := d.SubmitIPs(ctx, []string{"1.1.1.1", "1.1.1.2", "1.1.1.3", "1.1.1.4"}); err != nil {
t.Fatal(err)
}
finishWithChecks(t, d, "1.1.1.1", 1, 0, ResultPass)
finishWithChecks(t, d, "1.1.1.2", 1, 1, ResultPartial)
ip3, _ := d.GetIPByAddress(ctx, "1.1.1.3")
if err := d.CancelIP(ctx, ip3.ID); err != nil {
t.Fatal(err)
}
byState, total, err = d.CountIPsByState(ctx)
if err != nil || total != 4 || byState[IPDone] != 2 || byState[IPFailed] != 1 || byState[IPQueued] != 1 {
t.Fatalf("by state: %v total=%d err=%v", byState, total, err)
}
byResult, err := d.CountIPsByResult(ctx)
if err != nil || len(byResult) != 3 || byResult[ResultPass] != 1 || byResult[ResultPartial] != 1 || byResult[ResultCancelled] != 1 {
t.Fatalf("by result: %v err=%v", byResult, err)
}
if any, _ := d.AnyNonTerminalIP(ctx); !any {
t.Fatalf("a queued row is non-terminal")
}
ip4, _ := d.GetIPByAddress(ctx, "1.1.1.4")
if err := d.MarkFIPOccupied(ctx, ip4.ID, ""); err != nil {
t.Fatal(err)
}
if any, _ := d.AnyNonTerminalIP(ctx); any {
t.Fatalf("done/failed/occupied only: expected terminal")
}
}
// TestListRegistryPageMatchesListRegistry builds a mixed dataset (live rows
// with every overall result, an in-progress live row, deleted rows whose
// last cycle classifies as pass/partial/fail, and an address without checks)
// and verifies that ListRegistryPage's SQL filter selects exactly the rows
// ListRegistry+fillRegistrySummary labels with the same LastResult.
func TestListRegistryPageMatchesListRegistry(t *testing.T) {
d, ctx := newTestDB(t)
addrs := scaleAddrs(14)
if _, err := d.SubmitIPs(ctx, addrs); err != nil {
t.Fatal(err)
}
// live rows with an aggregated result
finishWithChecks(t, d, addrs[0], 2, 0, ResultPass)
finishWithChecks(t, d, addrs[1], 1, 1, ResultPartial)
finishWithChecks(t, d, addrs[2], 0, 2, ResultFail)
ip3, _ := d.GetIPByAddress(ctx, addrs[3])
if err := d.CancelIP(ctx, ip3.ID); err != nil {
t.Fatal(err)
}
// live row, passing checks recorded, but cycle unfinished -> no verdict
finishWithChecks(t, d, addrs[4], 2, 0, "")
// rows to be deleted: result derived from the checks of the last cycle
finishWithChecks(t, d, addrs[5], 2, 0, ResultPass) // -> pass
finishWithChecks(t, d, addrs[6], 1, 2, ResultPartial) // -> partial
finishWithChecks(t, d, addrs[7], 0, 2, ResultFail) // -> fail
finishWithChecks(t, d, addrs[8], 0, 0, "") // deleted, no checks -> ""
// a deleted row whose first cycle failed but whose last passed
finishWithChecks(t, d, addrs[9], 0, 1, ResultFail)
if _, err := d.DeleteIPs(ctx, []string{addrs[5], addrs[6], addrs[7], addrs[8], addrs[9]}); err != nil {
t.Fatal(err)
}
if _, err := d.SubmitIPs(ctx, []string{addrs[9]}); err != nil {
t.Fatal(err)
}
finishWithChecks(t, d, addrs[9], 1, 0, ResultPass)
if _, err := d.DeleteIPs(ctx, []string{addrs[9]}); err != nil {
t.Fatal(err)
}
all, err := d.ListRegistry(ctx)
if err != nil || len(all) != 14 {
t.Fatalf("list registry: n=%d err=%v", len(all), err)
}
// No filter: same rows in the same order as ListRegistry, paged.
var paged []RegistrySummary
for off := 0; ; off += 5 {
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{}, 5, off)
if err != nil || total != 14 {
t.Fatalf("page off=%d total=%d err=%v", off, total, err)
}
if len(page) == 0 {
break
}
paged = append(paged, page...)
}
if len(paged) != len(all) {
t.Fatalf("paged %d rows, want %d", len(paged), len(all))
}
for i := range all {
if all[i].IPAddress != paged[i].IPAddress || all[i].LastResult != paged[i].LastResult {
t.Fatalf("row %d differs: %+v vs %+v", i, all[i], paged[i])
}
}
for _, res := range []string{ResultPass, ResultPartial, ResultFail, ResultCancelled} {
var want []string
for _, s := range all {
if s.LastResult == res {
want = append(want, s.IPAddress)
}
}
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{LastResult: res}, 100, 0)
if err != nil {
t.Fatal(err)
}
var got []string
for _, s := range page {
got = append(got, s.IPAddress)
if s.LastResult != res {
t.Fatalf("%s: row %s has LastResult %q", res, s.IPAddress, s.LastResult)
}
}
sort.Strings(want)
sort.Strings(got)
if total != len(want) || !reflect.DeepEqual(got, want) || len(want) == 0 {
t.Fatalf("last_result=%s: total=%d got=%v want=%v", res, total, got, want)
}
}
// pass: addrs[0] (live) + addrs[5] + addrs[9] (deleted, latest cycle).
if _, total, _ := d.ListRegistryPage(ctx, RegistryFilter{LastResult: ResultPass}, 100, 0); total != 3 {
t.Fatalf("expected 3 pass rows, got %d", total)
}
// q filter + LIMIT applies after the filter, total is the filtered count.
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{Query: "10.0.0.1"}, 2, 0)
if err != nil || total != 5 || len(page) != 2 { // 10.0.0.1, .10-.13
t.Fatalf("q filter: total=%d len=%d err=%v", total, len(page), err)
}
page, total, _ = d.ListRegistryPage(ctx, RegistryFilter{Query: "10.0.0.1", LastResult: ResultPass}, 10, 0)
if total != 0 || len(page) != 0 {
// 10.0.0.1 is partial; 10.0.0.10-13 have no verdict or fail.
t.Fatalf("q+last_result: total=%d page=%+v", total, page)
}
}
func TestClearAllIPsKeepsHistoryAndFreesValidators(t *testing.T) {
d, ctx := newTestDB(t)
if err := d.AdminCreateValidator(ctx, "validator-1", "port-1"); err != nil {
t.Fatal(err)
}
addrs := []string{"5.5.5.1", "5.5.5.2", "5.5.5.3"}
if _, err := d.SubmitIPs(ctx, addrs); err != nil {
t.Fatal(err)
}
finishWithChecks(t, d, "5.5.5.2", 1, 1, ResultPartial)
claimed, err := d.ClaimNextQueued(ctx, "validator-1", time.Minute)
if err != nil || claimed == nil {
t.Fatalf("claim: %v %v", claimed, err)
}
if err := d.SetFIPAssociated(ctx, claimed.ID, "fip-9", time.Minute); err != nil {
t.Fatal(err)
}
if err := d.SetEgressComplete(ctx, claimed.ID); err != nil {
t.Fatal(err)
}
if err := d.SetSiteComplete(ctx, claimed.ID, 1); err != nil {
t.Fatal(err)
}
refs, err := d.ListFIPRefs(ctx)
if err != nil || len(refs) != 1 || refs[0].FIPID != "fip-9" || refs[0].IPAddress != claimed.IPAddress {
t.Fatalf("fip refs: %+v err=%v", refs, err)
}
refs, err = d.ListFIPRefsByAddresses(ctx, []string{claimed.IPAddress, "nope"})
if err != nil || len(refs) != 1 {
t.Fatalf("fip refs by address: %+v err=%v", refs, err)
}
if refs, _ := d.ListFIPRefsByAddresses(ctx, []string{"5.5.5.3"}); len(refs) != 0 {
t.Fatalf("row without fip must not be listed: %+v", refs)
}
deleted, err := d.ClearAllIPs(ctx)
if err != nil {
t.Fatalf("clear: %v", err)
}
sort.Strings(deleted)
if !reflect.DeepEqual(deleted, addrs) {
t.Fatalf("deleted %v, want %v", deleted, addrs)
}
if items, _ := d.ListIPs(ctx); len(items) != 0 {
t.Fatalf("queue not empty: %+v", items)
}
v, err := d.GetValidator(ctx, "validator-1")
if err != nil || v.State != ValidatorIdle || v.CurrentIPID != nil {
t.Fatalf("validator not freed: %+v err=%v", v, err)
}
// History and registry rows survive, detached from the queue.
reg, err := d.GetRegistryByAddress(ctx, "5.5.5.2")
if err != nil || reg.TotalCycles != 1 || reg.LastResult != ResultPartial || reg.InQueue {
t.Fatalf("registry after clear: %+v err=%v", reg, err)
}
checks, err := d.ListChecksForRegistry(ctx, reg.ID, nil)
if err != nil || len(checks) != 2 || checks[0].IPID != 0 {
t.Fatalf("checks after clear: %+v err=%v", checks, err)
}
// Clearing an empty queue is fine and returns an empty (non-nil) list.
if deleted, err := d.ClearAllIPs(ctx); err != nil || deleted == nil || len(deleted) != 0 {
t.Fatalf("second clear: %v %v", deleted, err)
}
// The same addresses can be re-submitted afterwards.
if res, err := d.SubmitIPs(ctx, addrs); err != nil || len(res.Added) != 3 {
t.Fatalf("resubmit: %+v err=%v", res, err)
}
}
func TestMigration0009Indexes(t *testing.T) {
d, ctx := newTestDB(t)
for _, name := range []string{"idx_ip_queue_registry", "idx_ip_queue_state_aggregated"} {
var n int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=?`, name).Scan(&n); err != nil || n != 1 {
t.Fatalf("index %s missing (n=%d err=%v)", name, n, err)
}
}
var ver int
if err := d.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&ver); err != nil || ver < 9 {
t.Fatalf("user_version=%d err=%v", ver, err)
}
}
// TestScaleSmoke6440 pushes a realistic project size through the hot paths
// with a loose time bound: the point is the absence of O(n^2) / N+1 work, not
// a benchmark.
func TestScaleSmoke6440(t *testing.T) {
if testing.Short() {
t.Skip("scale smoke test skipped in -short mode")
}
d, ctx := newTestDB(t)
addrs := scaleAddrs(6440)
start := time.Now()
for off := 0; off < len(addrs); off += 500 {
end := min(off+500, len(addrs))
if _, err := d.SubmitIPs(ctx, addrs[off:end]); err != nil {
t.Fatalf("submit chunk: %v", err)
}
}
submitDur := time.Since(start)
start = time.Now()
page, total, err := d.ListRegistryPage(ctx, RegistryFilter{}, 100, 3000)
if err != nil || total != 6440 || len(page) != 100 {
t.Fatalf("registry page: total=%d len=%d err=%v", total, len(page), err)
}
if _, total, err = d.ListRegistryPage(ctx, RegistryFilter{LastResult: ResultPass}, 100, 0); err != nil || total != 0 {
t.Fatalf("registry last_result filter: total=%d err=%v", total, err)
}
registryDur := time.Since(start)
start = time.Now()
if items, total, err := d.ListIPsPage(ctx, IPFilter{States: []string{IPQueued}, Query: "10.0.1."}, 50, 0); err != nil || total != 256 || len(items) != 50 {
t.Fatalf("ips page: total=%d len=%d err=%v", total, len(items), err)
}
if by, total, err := d.CountIPsByState(ctx); err != nil || total != 6440 || by[IPQueued] != 6440 {
t.Fatalf("count: %v %d %v", by, total, err)
}
if any, err := d.AnyNonTerminalIP(ctx); err != nil || !any {
t.Fatalf("any non terminal: %v %v", any, err)
}
queryDur := time.Since(start)
start = time.Now()
deleted, err := d.ClearAllIPs(ctx)
if err != nil || len(deleted) != 6440 {
t.Fatalf("clear: n=%d err=%v", len(deleted), err)
}
clearDur := time.Since(start)
t.Logf("submit=%v registry=%v queries=%v clear=%v", submitDur, registryDur, queryDur, clearDur)
if registryDur > 10*time.Second || queryDur > 5*time.Second || clearDur > 10*time.Second {
t.Fatalf("too slow: registry=%v queries=%v clear=%v", registryDur, queryDur, clearDur)
}
}
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more