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>
206 lines
25 KiB
Markdown
206 lines
25 KiB
Markdown
# Admin Dashboard
|
||
|
||
`admin-dashboard` — 4-й компонент системы: браузерная веб-панель,
|
||
дающая полное покрытие административного API `control-api`
|
||
([docs/API.md](API.md#управление-очередью-и-конфигурацией)) без единого
|
||
`curl`. Отдельный, полностью самостоятельный процесс — не хранит
|
||
состояния, не подключается к базе данных напрямую, общается с
|
||
`control-api` только через его же HTTP admin API.
|
||
|
||
## Устройство
|
||
|
||
- Рендеринг полностью на сервере: `html/template` + [htmx](https://htmx.org)
|
||
(частичные обновления без перезагрузки страницы) +
|
||
[Alpine.js](https://alpinejs.dev) (точечная клиентская интерактивность).
|
||
Оформление — собственная небольшая дизайн-система
|
||
(`internal/dashboard/static/dashboard.css`), не построена на готовом
|
||
CSS-фреймворке: боковая навигация, карточки-панели со скруглением и
|
||
мягкой тенью, статусы — пастельные «пилюли» (не Bootstrap-бейджи).
|
||
Шрифты — Manrope (интерфейс/заголовки) и IBM Plex Mono (IP-адреса,
|
||
таймстампы, числа — моноширинные для табличного выравнивания), с
|
||
кириллицей. Адаптивна: боковая панель на узких экранах уходит в
|
||
выдвижное меню, таблицы складываются в карточки. Никакой сборки
|
||
фронтенда нет — шрифты и JS-библиотеки вендорены как статические файлы
|
||
(`internal/dashboard/static/vendor/`, см. `VENDOR.md` там же) и встроены
|
||
в бинарник через `//go:embed`. Дашборд работает полностью офлайн — при
|
||
открытии страницы браузер не делает ни одного запроса за пределы самого
|
||
дашборда (можно проверить через DevTools → Network).
|
||
- Браузер никогда не видит JSON `control-api` напрямую: каждая страница и
|
||
каждый htmx-фрагмент — это HTML, отрендеренный Go-хендлером дашборда
|
||
после вызова `control-api`. CORS и reverse-proxy не нужны.
|
||
- Как и `control-api`, дашборд **не аутентифицирован** — ограничивайте
|
||
доступ на уровне сети/файрвола (см.
|
||
[SETUP.md](SETUP.md#сетевые-доступы)).
|
||
|
||
## Запуск
|
||
|
||
```bash
|
||
cp configs/admin-dashboard.example.yaml /etc/cloud-ip-validator/admin-dashboard.yaml
|
||
# отредактируйте control_api.base_url под ваш стенд
|
||
admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
||
```
|
||
|
||
Развёртывание как systemd-юнита — по образцу остальных компонентов, см.
|
||
[SETUP.md](SETUP.md#развёртывание-admin-dashboard). Порт по умолчанию —
|
||
`:8090` (у `control-api` — `:8080`).
|
||
|
||
## Страницы и что на них можно делать
|
||
|
||
| Страница | Назначение |
|
||
|---|---|
|
||
| `/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` | Полная очередь. Форма сверху принимает список адресов (по одному на строке или через запятую) и отправляет их в `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}`, которая показывает только текущую попытку. |
|
||
| `/validators` | Список валидаторов + создание/изменение `os_port_id`/удаление. |
|
||
| `/sites` | Площадки — число слотов не ограничено, форма сверху добавляет новый слот, назначить/сменить/освободить `site_id` в каждой строке; колонка «Статус» показывает бейдж подключения пробера (`unregistered`/`idle`/`unreachable`, по аналогии с `/validators`), см. [USAGE.md](USAGE.md#состояния-площадки). |
|
||
| `/targets` | Группы целей для egress-проверок — создание/редактирование/удаление. |
|
||
| `/check-types` | Типы проверок (`https`/`icmp`/`ssh`/...), включение/выключение, привязка к группам целей. |
|
||
| `/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`). Дашборд ничего не меняет в этом
|
||
устройстве и не заводит своего состояния:
|
||
|
||
- **Текущая проверка** — все адреса, которые прямо сейчас не в
|
||
состоянии `done`/`failed` (`queued`, `assigning_fip`,
|
||
`awaiting_self_check`, `checking`, `aggregating`), вычисляется заново на
|
||
каждый запрос из `GET /api/v1/admin/status` + `GET /api/v1/admin/ips`.
|
||
- **Последняя завершённая проверка** — последние N адресов, перешедших в
|
||
`done`/`failed`, отсортированные по `AggregatedAt` по убыванию (не
|
||
«последний запуск», а именно скользящее окно последних по времени
|
||
завершений).
|
||
|
||
### Поиск по IP и фильтр по статусу
|
||
|
||
На `/overview` и `/registry` есть форма из двух полей — поиск по IP
|
||
(подстрока, без учёта регистра) и выпадающий список статуса
|
||
(`pass`/`partial`/`fail`/`cancelled`). Оба поля работают вместе (И, а не
|
||
ИЛИ) и применяются целиком на стороне дашборда — `client.ListIPs`/
|
||
`client.ListRegistry` всегда получают от `control-api` полный список,
|
||
`internal/httpapi`/`internal/db` про фильтр вообще не знают.
|
||
|
||
- **`/overview`** — фильтр действует на обе таблицы сразу («Текущая
|
||
проверка» и «Последние N завершённых»). Статус — это фильтр по
|
||
итоговому результату (`OverallResult`), поэтому выбор конкретного
|
||
статуса скрывает «Текущую проверку» целиком: у ещё идущих проверок
|
||
результата попросту нет. Панель статистики (счётчики сверху) фильтру не
|
||
подчиняется — это агрегаты по всей очереди, а не по видимым строкам.
|
||
- **`/registry`** — тот же принцип, но по одной таблице (`LastResult`), и
|
||
значения полей отражаются в адресной строке (`?q=&status=`) через
|
||
`hx-replace-url` — отфильтрованную ссылку можно сохранить или переслать,
|
||
а обновление страницы (F5) сохраняет применённый фильтр.
|
||
|
||
**Раскладка `/overview` сверху вниз**: панель статистики → форма
|
||
фильтра → таблицы. Панель статистики и форма фильтра физически лежат
|
||
*вне* поллящегося блока (иначе периодическое обновление стирало бы
|
||
набранный текст/выбор — ровно то, из-за чего в своё время отказались от
|
||
auto-refresh на `/ips`, см. git-историю). Опрашивается только блок
|
||
`#overview-tables`; чтобы панель статистики (`#overview-stats`) при этом
|
||
тоже обновлялась каждый тик, `/overview/fragment` дополнительно
|
||
рендерит её как out-of-band swap (`hx-swap-oob`) — тот же приём, которым
|
||
уже обновляется общий баннер ошибок (`templates/layout.html`,
|
||
`error_banner`). При правках вёрстки `/overview` важно сохранять именно
|
||
этот порядок и не переносить форму фильтра/панель статистики обратно
|
||
внутрь опрашиваемого блока.
|
||
|
||
Индикатор автоцикла лежит **внутри** панели статистики
|
||
(`overview_stats`), поэтому обновляется тем же out-of-band swap'ом без
|
||
отдельного механизма и не меняет порядок блоков. Статус цикла
|
||
запрашивается у control-api при каждом обновлении; если запрос не удался
|
||
(например, control-api старой версии без этой ручки), индикатор просто не
|
||
показывается — остальная страница не страдает, баннер ошибки не выводится.
|
||
|
||
### Добавление адресов и принудительный повтор — один и тот же вызов
|
||
|
||
Форма на `/ips` всегда бьёт в `POST /api/v1/admin/ips`. Поведение зависит
|
||
от текущего состояния каждого конкретного адреса (см.
|
||
[docs/API.md](API.md#post-apiv1adminips)): новый — встаёт в очередь;
|
||
уже `done`/`failed` — принудительно перезапускается; уже `queued` —
|
||
просто переупорядочивается; уже активно проверяется — не трогается
|
||
(дашборд честно показывает это в таблице, а не делает вид, что запрос
|
||
ничего не значил).
|
||
|
||
### Удаление адресов — безвозвратно из очереди, но не из реестра
|
||
|
||
«Отменить» (`POST .../cancel`) останавливает проверку, но сохраняет
|
||
адрес и его историю как `failed`/`cancelled` — он остаётся виден в
|
||
очереди. «Удалить» (кнопка в строке, «Удалить выбранные» по чекбоксам,
|
||
«Очистить всё») убирает строку из `/ips` безвозвратно — работает из
|
||
любого состояния, включая активно проверяемое (Floating IP отвязывается,
|
||
валидатор освобождается). Все три операции удаления в UI защищены
|
||
`hx-confirm` с формулировкой, отражающей необратимость — «Очистить всё»
|
||
предупреждает отдельно, так как затрагивает и активные проверки.
|
||
|
||
**Накопленная история при этом не теряется** — она остаётся в
|
||
[реестре](#реестр-адресов) (`/registry/{ip}`) даже после того, как адрес
|
||
пропал из `/ips`, и продолжает пополняться, если адрес позже добавят
|
||
заново. Подробнее —
|
||
[API.md](API.md#delete-apiv1adminipsip-post-apiv1adminipsdelete-post-apiv1adminipsclear)
|
||
и [USAGE.md](USAGE.md#удаление-адресов-из-очереди).
|
||
|
||
### Реестр адресов
|
||
|
||
`/registry` решает задачу, которую `/ips` принципиально не может: история
|
||
проверок конкретного адреса не должна теряться только из-за того, что его
|
||
временно вывели из очереди (например, адрес переиспользуют для другой
|
||
цели) и позже добавили обратно — возможно, под другим циклом проверки.
|
||
Каждая запись реестра живёт всё время, что адрес когда-либо существовал в
|
||
системе, и не удаляется вместе со строкой `ip_queue`. Единственное, что
|
||
можно ограничить — глубину детальной истории проверок на один адрес
|
||
(`history_retention_cycles` на `/settings`, по циклам, а не по времени);
|
||
сама запись в реестре (когда адрес впервые встречен, сколько всего было
|
||
циклов) остаётся всегда. Подробнее —
|
||
[API.md](API.md#реестр-адресов-и-история-проверок).
|
||
|
||
## Вход и сессия
|
||
|
||
Если заданы `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`. Ключевые поля:
|
||
|
||
- `server.listen_addr` — где слушает сам дашборд (по умолчанию `:8090`).
|
||
- `control_api.base_url` — адрес `control-api`, обязателен.
|
||
- `control_api.timeout_seconds` — таймаут HTTP-запросов к `control-api`.
|
||
- `overview.last_completed_count` — размер окна «последних завершённых»
|
||
на странице обзора.
|
||
- `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). Подробности — [«Вход и сессия»](#вход-и-сессия).
|
||
|
||
## Отображение ошибок
|
||
|
||
Любая ошибка `control-api` (4xx/5xx с телом `{"error":"..."}`) или сбой
|
||
связи с ним (недоступен, таймаут) показывается баннером наверху страницы,
|
||
а не приводит к падению дашборда — таблица/страница при этом всегда
|
||
отражает актуальное состояние `control-api` (дашборд перезапрашивает
|
||
данные после любой попытки мутации, независимо от её исхода). Жёлтый
|
||
баннер — бизнес-ошибка (4xx, например «валидатор занят»), красный —
|
||
инфраструктурная проблема (5xx или `control-api` недоступен).
|