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>
16 KiB
План: аутентификация API и UI
Дата: 2026-10-01 11:12 MSK · Статус: реализовано — результаты ревью и тестов: 2026-10-01_11-31_authentication-review.md
Context
Сейчас ни control-api, ни admin-dashboard не имеют аутентификации (прямо сказано в docs/API.md, раздел «Важно»):
любой, кто достучится до порта, может менять очередь и конфигурацию, а в дашборд зайти без пароля. Нужно:
- закрыть ручки управления (
/api/v1/admin/*) токеном администратора; - оставить
validator-agentиproberвозможность забрать свою настройку/задание без аутентификации, но не дать подделывать результаты проверок — записывающие вызовы закрыть отдельным токеном агентов (без срока жизни); - закрыть 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дашборда/агента/пробера (whitelistenvsubst+ новые переменные),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-токена). Если нужны раздельные токены для валидаторов и проберов — добавляется второй уровень доступа в ту же таблицу. - Политика «стартовать с предупреждением» распространена и на дашборд (без заданных логина/пароля вход не требуется). Строгий режим (отказ стартовать) — отдельное небольшое изменение.
- Один пользователь-администратор дашборда; многопользовательность и роли не вводятся.
Верификация
go build ./... && go vet ./... && go test ./...иgo test -raceдляhttpapi,dashboard,apiclient.scripts/run-local-e2e.shс токенами:401без токена на admin- и agent-write-ручках, штатный проход цикла с токенами.- Вручную (curl):
GET /api/v1/admin/statusбез токена →401, сBearer $ADMIN→200;POST /agents/{id}/resultsс admin-токеном →401;GET /agents/{id}/assignmentбез токена →200/204. - Вручную (браузер): дашборд редиректит на
/login; неверный пароль → ошибка, 5 неудач →429; после входа «Обзор» обновляется без перезагрузки; «Выйти» возвращает на/login; через 8 часов (илиsession_ttl_minutes) сессия истекает. - Раскатка по шагам из раздела 4 на стенде
rxprod-compose: сервисы не теряют регистрацию в момент включения токенов.