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:
1 parent
972ad47d0c
commit
debf2afed2
67 files changed
+2050
-105
No files matched your search
@@ -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`: сервисы не теряют регистрацию в момент включения токенов.
|
||||
Reference in new issue
Block a user