# План: аутентификация 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 ` ко **всем** запросам к 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`: сервисы не теряют регистрацию в момент включения токенов.