Files
cloud-ip-validator/docs/changes/2026-10-01_11-12_authentication-plan.md
T
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

132 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План: аутентификация API и 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`: сервисы не теряют регистрацию в момент включения токенов.