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>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-01 11:35:24 +03:00
1 parent 972ad47d0c
commit debf2afed2
67 files changed
+2050 -105

No files matched your search

+33 -9
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,6 +21,7 @@ JSON, базовый префикс прикладных методов — `/ap
## Содержание
- [Аутентификация](#аутентификация)
- [Общие соглашения](#общие-соглашения)
- [Методы для validator-agent](#методы-для-validator-agent)
- [Методы для prober](#методы-для-prober)
@@ -33,6 +32,31 @@ JSON, базовый префикс прикладных методов — `/ap
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
- [Сквозной пример работы (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`; `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments` |
- Токены разные: токен администратора **не** подходит для методов агентов, и наоборот.
- Валидатор и пробер могут без токена зарегистрироваться, слать heartbeat и забирать задание (настройку); отправка результатов без токена агентов — `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`).
+25
View File
@@ -157,6 +157,26 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
циклов) остаётся всегда. Подробнее —
[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`. Ключевые поля:
@@ -168,6 +188,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). Подробности — [«Вход и сессия»](#вход-и-сессия).
## Отображение ошибок
+29
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
+6
View File
@@ -7,6 +7,12 @@
[API.md](API.md) — здесь мы используем их только как инструмент, не
углубляясь в протокол.
> **Токен в примерах.** Если на стенде включена аутентификация
> ([SETUP.md](SETUP.md#5-аутентификация-токены-и-пароль-дашборда)), к вызовам
> `/api/v1/admin/*` в примерах `curl` ниже нужно добавлять заголовок
> `-H "Authorization: Bearer $ADMIN_TOKEN"` (значение `CONTROL_API_ADMIN_TOKEN`);
> для краткости он опущен. Дашборд запрашивает логин и пароль.
## Содержание
- [Как устроена работа с системой](#как-устроена-работа-с-системой)
@@ -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).