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

16 KiB
Raw Blame History

План: аутентификация API и UI

Дата: 2026-10-01 11:12 MSK · Статус: реализовано — результаты ревью и тестов: 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: сервисы не теряют регистрацию в момент включения токенов.