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:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-01 11:35:24 +03:00
1 parent 972ad47d0c
commit debf2afed2
67 files changed
+2050 -105

No files matched your search

+20 -7
View File
@@ -38,6 +38,7 @@ docker compose up -d --build # весь стенд на одной
| `orchestrator.heartbeat_timeout_seconds` | После скольких секунд тишины валидатор или площадка считаются потерянными (30) |
| `orchestrator.fip_settle_seconds` | Только начальное значение: пауза между привязкой FIP и self-check; далее управляется на лету. Должно выполняться `fip_settle_seconds + self_check_timeout_seconds < lease_ttl_seconds` |
| `orchestrator.fip_scan_interval_seconds` | Периодический скан Floating IP (0 — выключен). Не выполняется, пока включён автоматический цикл |
| `auth.admin_token_env`, `auth.agent_token_env` | Имена переменных окружения с токеном администратора (`CONTROL_API_ADMIN_TOKEN`) и токеном агентов (`CONTROL_API_AGENT_TOKEN`). Значения в YAML не хранятся; пустой токен — соответствующий уровень API открыт (с предупреждением в логе) |
| `aggregation.missing_counts_as_fail` | Отсутствие ответа источника засчитывается как провал (`true`) |
| `validators`, `sites`, `check_types`, `targets`, `inbound_checks` | Начальная загрузка пустой БД: валидаторы (`validator_id` + `os_port_id`), внешние площадки, типы и цели egress-проверок, порты inbound-проверок. Дальше источник истины — БД, правки через API/UI |
| `ip_addresses` | Адреса, которые доливаются в очередь при каждом старте (только новые) |
@@ -45,9 +46,11 @@ docker compose up -d --build # весь стенд на одной
**Остальные компоненты**
| Компонент | Ключи |
|---|---|
| `validator-agent` | `validator_id`, `control_api_url`, `poll_interval_seconds`, `self_check.*` (таймаут, `ip_echo_urls`), `checks.*` (таймауты HTTPS/ICMP, число ICMP-пакетов, `ssh.*`) |
| `prober` | `site_id`, `control_api_url`, `poll_interval_seconds`, `checks.*` (таймауты TCP/ICMP, число ICMP-пакетов) |
| `admin-dashboard` | `server.listen_addr` (`:8090`), `control_api.base_url`, `control_api.timeout_seconds`, `overview.last_completed_count` (20), `overview.poll_interval_seconds` (5) |
| `validator-agent` | `validator_id`, `control_api_url`, `control_api_token_env` (`CONTROL_API_AGENT_TOKEN`), `poll_interval_seconds`, `self_check.*` (таймаут, `ip_echo_urls`), `checks.*` (таймауты HTTPS/ICMP, число ICMP-пакетов, `ssh.*`) |
| `prober` | `site_id`, `control_api_url`, `control_api_token_env` (`CONTROL_API_AGENT_TOKEN`), `poll_interval_seconds`, `checks.*` (таймауты TCP/ICMP, число ICMP-пакетов) |
| `admin-dashboard` | `server.listen_addr` (`:8090`), `control_api.base_url`, `control_api.timeout_seconds`, `control_api.token_env` (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`), `auth.username_env` / `password_env` / `session_secret_env` (`ADMIN_DASHBOARD_USERNAME` / `_PASSWORD` / `_SESSION_SECRET`), `auth.session_ttl_minutes` (480), `overview.last_completed_count` (20), `overview.poll_interval_seconds` (5) |
Секреты (токены, пароль дашборда, ключ сессии) задаются **только переменными окружения**; генерация — `openssl rand -hex 32`. Подробности и порядок включения — [docs/SETUP.md](docs/SETUP.md#5-аутентификация-токены-и-пароль-дашборда).
Настройки, меняющиеся на лету (пауза перед self-check, глубина истории, типы inbound-проверок, автоматический цикл, валидаторы, площадки, цели), хранятся в БД и правятся через API или страницу `/settings`.
@@ -122,12 +125,20 @@ docs/ документация и планы доработок
**Соглашения**
- Тело запросов и ответов — JSON. Успех — `200`, `204` — когда данных нет (например, у валидатора нет назначения).
- Ошибки — `4xx`/`5xx` с телом `{"error": "..."}`; неизвестная сущность — `404`, конфликт состояния — `409`, неверные данные — `400`, ошибка OpenStack при скане — `502`.
- Ошибки — `4xx`/`5xx` с телом `{"error": "..."}`; нет или неверен токен — `401` (с `WWW-Authenticate: Bearer`), неизвестная сущность — `404`, конфликт состояния — `409`, неверные данные — `400`, ошибка OpenStack при скане — `502`.
- Токен передаётся заголовком `Authorization: Bearer <токен>` ([docs/API.md](docs/API.md#аутентификация)).
- Времена — RFC 3339. Полная спецификация и примеры `curl` — [docs/API.md](docs/API.md).
## Безопасность
- **Аутентификации API нет** — это известное ограничение текущей версии: эндпоинты, включая административные, доступны любому, кто достучится до порта `control-api`. Доступ ограничивается на уровне сети/файрвола ([docs/SETUP.md](docs/SETUP.md#сетевые-доступы)); bearer-токен — направление доработки.
- Дашборд — тонкий прокси к API без собственных учётных записей и состояния; публиковать его так же нужно только в доверенном сегменте или за reverse-proxy с авторизацией.
- **Доступ к API — два статических Bearer-токена** (без срока жизни, из переменных окружения, сравнение в константное время):
| Уровень | Токен | Методы |
|---|---|---|
| admin | `CONTROL_API_ADMIN_TOKEN` | все `/api/v1/admin/*` |
| agent | `CONTROL_API_AGENT_TOKEN` | запись результатов валидатора и пробера: `self-check`, `events`, `results`, `complete` |
| открыто | — | `GET /healthz`, `register`, `heartbeat` и получение задания (`GET assignment` / `assignments`) |
Токены разные: административный не открывает методы агентов, и наоборот. Валидатор и пробер получают настройку и задание без токена, но не могут отправить результат без токена агентов.
- **Пустой токен — уровень открыт** (обратная совместимость): `control-api` стартует с предупреждением в логе. На реальном стенде задайте оба токена и ограничьте доступ на уровне сети ([docs/SETUP.md](docs/SETUP.md#сетевые-доступы)); токены идут открытым текстом без TLS — публикуйте через reverse-proxy с TLS. Включать токен агентов нужно **после** его раздачи валидаторам и проберам ([порядок](docs/SETUP.md#5-аутентификация-токены-и-пароль-дашборда)).
- **Дашборд закрыт логином и паролем** (один администратор; пароль и ключ сессии — из env). Сессия — подписанная cookie (`HttpOnly`, `SameSite=Strict`, без состояния на сервере), CSRF-защита по `Origin`, 5 неудачных входов за 10 минут с одного IP → `429`. Без заданных логина/пароля дашборд открыт (с предупреждением в логе). Дашборд ходит в API с токеном администратора. Подробности — [docs/DASHBOARD.md](docs/DASHBOARD.md#вход-и-сессия).
- Учётные данные OpenStack передаются только через переменные окружения процесса (`EnvironmentFile=` в systemd, `OS_*` в Docker) и не попадают в YAML; режим `password` перевыпускает токен сам, режим `token` — нет.
- Компоненты работают по pull-модели: на валидаторах и площадках не нужно открывать входящие порты для `control-api`.
- Бинарники статические, без `cgo`; целостность проверяется `sha256sum -c bin/SHA256SUMS`.
@@ -159,7 +170,7 @@ go build ./... && go vet ./... && go test ./... # юнит-тесты: db,
go test -race ./internal/orchestrator ./internal/httpapi ./internal/dashboard ./internal/db
scripts/run-local-e2e.sh # сквозной прогон: lease-reclaim, перепроверка, автоматический цикл
```
Юнит-тесты используют временную SQLite и `MockClient`, внешних ресурсов не требуют. E2E поднимает все компоненты локальными процессами и завершается ненулевым кодом при провале проверок автоцикла — [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md).
Юнит-тесты используют временную SQLite и `MockClient`, внешних ресурсов не требуют. E2E поднимает все компоненты локальными процессами с включёнными токенами (проверяет `401`/открытые маршруты и работу агента и пробера с токеном) и завершается ненулевым кодом при провале проверок автоцикла — [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md).
## Документация
| Документ | Для чего |
@@ -170,6 +181,7 @@ scripts/run-local-e2e.sh # сквозной прог
| [docs/DASHBOARD.md](docs/DASHBOARD.md) | Устройство `admin-dashboard`: страницы, поиск и фильтр, обработка ошибок |
| [docs/DIAGRAMS.md](docs/DIAGRAMS.md) | Диаграммы потоков данных: control plane, egress-проверка, телеметрия |
| [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md) | Полностью офлайн-прогон всей системы одним скриптом |
| [docs/changes/](docs/changes/) | Планы доработок и отчёты ревью с отметкой времени в имени файла (последняя: [аутентификация](docs/changes/2026-10-01_11-31_authentication-review.md)) |
| [docs/CONTROL_DATA_PLANE.html](docs/CONTROL_DATA_PLANE.html) | Презентационные схемы control/data plane для docker-compose-деплоя — открыть в браузере |
## История изменений
@@ -185,6 +197,7 @@ scripts/run-local-e2e.sh # сквозной прог
| Дата | Веха | Документ |
|---|---|---|
| 2026-10-01 | Аутентификация: токены администратора и агентов для API, логин и пароль для дашборда | [план](docs/changes/2026-10-01_11-12_authentication-plan.md) · [ревью и тесты](docs/changes/2026-10-01_11-31_authentication-review.md) · [API](docs/API.md#аутентификация) |
| 2026-10-01 | Автоматический цикл проверок по сценарию: очистка → скан FIP → проверка → пауза | [USAGE](docs/USAGE.md#автоматический-цикл-проверок) · [API](docs/API.md#автоматический-цикл-проверок) |
| 2026-09-23 | Сканирование Floating IP и устойчивый реестр адресов с настраиваемой глубиной истории | [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) |
| 2026-09-23 | Поиск по IP и фильтр по статусу на «Обзоре» и «Реестре» | [DASHBOARD](docs/DASHBOARD.md) |
+4 -4
View File
@@ -1,4 +1,4 @@
fb8aaab7c07c3702d274aa15fcb299690bb738cbde9faad79e73d726c24e9d1b control-api
d7377e90cc34549933f0de8556df113f7ace7425ad0fee96f723ed6a4510d66b validator-agent
c3c9e42ad7632cd8a4b88fe5eedf341cf0dcba6ecf5281d247733a98cee91fb6 prober
23d1c10c26366dedd790025689eae63677316c7bb9dafb53c8cebca340dfe77f admin-dashboard
a0eac5409a2929d641ef2a217c31f1b6a974a8679da866ebbb50ff1d5b57df8d control-api
5d7fb2f476871843ae4179dddb61e77d3f71e8eb73a00e331f0119d8893ba813 validator-agent
23c687a988350c61f486d400af654bd563865fff59421c437eb679e0e4e10500 prober
90921ad25f16a922124368770e439d1228c5b0bf7126ba6c726f3fb98bb22f7a admin-dashboard
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+10
View File
@@ -39,11 +39,21 @@ func run(configPath string, log *slog.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
controlAPIToken := os.Getenv(cfg.ControlAPI.TokenEnv)
if controlAPIToken == "" {
log.Warn("control-api admin token is not set: calls to control-api are sent without credentials", "env", cfg.ControlAPI.TokenEnv)
}
srv, err := dashboard.New(dashboard.Config{
ControlAPIBaseURL: cfg.ControlAPI.BaseURL,
ControlAPITimeout: time.Duration(cfg.ControlAPI.TimeoutSeconds) * time.Second,
LastCompletedCount: cfg.Overview.LastCompletedCount,
OverviewPollIntervalS: cfg.Overview.PollIntervalSeconds,
ControlAPIToken: controlAPIToken,
Username: os.Getenv(cfg.Auth.UsernameEnv),
Password: os.Getenv(cfg.Auth.PasswordEnv),
SessionSecret: os.Getenv(cfg.Auth.SessionSecretEnv),
SessionTTL: time.Duration(cfg.Auth.SessionTTLMinutes) * time.Minute,
}, log)
if err != nil {
return fmt.Errorf("init dashboard: %w", err)
+10 -1
View File
@@ -60,7 +60,16 @@ func run(configPath string, log *slog.Logger) error {
orch := orchestrator.New(database, osClient, cfg, log)
srv := httpapi.New(database, orch, log)
adminToken := os.Getenv(cfg.Auth.AdminTokenEnv)
agentToken := os.Getenv(cfg.Auth.AgentTokenEnv)
if adminToken == "" {
log.Warn("admin API is open: admin token is not set", "env", cfg.Auth.AdminTokenEnv)
}
if agentToken == "" {
log.Warn("agent write API is open: agent token is not set", "env", cfg.Auth.AgentTokenEnv)
}
srv := httpapi.New(database, orch, log).WithAuth(adminToken, agentToken)
httpServer := &http.Server{Addr: cfg.Server.ListenAddr, Handler: srv.Handler()}
go runOrchestratorLoop(ctx, orch, cfg, log)
+5 -1
View File
@@ -31,7 +31,11 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
prober := probercore.New(cfg, log)
token := os.Getenv(cfg.ControlAPITokenEnv)
if token == "" {
log.Warn("agent token is not set: result calls will be rejected by an authenticated control-api", "env", cfg.ControlAPITokenEnv)
}
prober := probercore.New(cfg, log).WithToken(token)
if err := prober.Run(ctx); err != nil && err != context.Canceled {
log.Error("prober stopped", "err", err)
os.Exit(1)
+5 -1
View File
@@ -39,7 +39,11 @@ func main() {
startStubListeners(ctx, log, *stubPorts)
}
agent := agentcore.New(cfg, log)
token := os.Getenv(cfg.ControlAPITokenEnv)
if token == "" {
log.Warn("agent token is not set: result/event/complete calls will be rejected by an authenticated control-api", "env", cfg.ControlAPITokenEnv)
}
agent := agentcore.New(cfg, log).WithToken(token)
if err := agent.Run(ctx); err != nil && err != context.Canceled {
log.Error("agent stopped", "err", err)
os.Exit(1)
+17
View File
@@ -4,6 +4,23 @@ server:
control_api:
base_url: "http://control-api.internal:8080"
timeout_seconds: 10
# Имя переменной окружения с admin-токеном control-api
# (CONTROL_API_ADMIN_TOKEN на стороне control-api). Пусто в окружении —
# запросы идут без заголовка Authorization.
token_env: "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
# Вход в дашборд: один администратор, сессия в cookie. Здесь только ИМЕНА
# переменных окружения; значения задаются окружением процесса (см.
# deploy/systemd/admin-dashboard.service). Если логин или пароль не заданы —
# вход не требуется (в логе предупреждение при старте).
auth:
username_env: "ADMIN_DASHBOARD_USERNAME"
password_env: "ADMIN_DASHBOARD_PASSWORD"
# Ключ подписи cookie сессии (openssl rand -hex 32). Не задан — ключ
# случайный на каждый старт, и рестарт сбрасывает все сессии.
session_secret_env: "ADMIN_DASHBOARD_SESSION_SECRET"
# Срок жизни сессии, минут (по умолчанию 480 = 8 часов).
session_ttl_minutes: 480
# Настройки сводки на странице "Обзор" — см. docs/DASHBOARD.md. Оба поля
# влияют только на то, как дашборд группирует уже существующие данные
+16
View File
@@ -37,6 +37,22 @@ openstack:
user_domain_name_env: "OS_USER_DOMAIN_NAME"
password_env: "OS_PASSWORD"
# Аутентификация API: здесь только ИМЕНА переменных окружения, значения
# (статические bearer-токены) задаются окружением процесса — см.
# deploy/systemd/control-api.service (EnvironmentFile=). Генерация:
# openssl rand -hex 32
# Пустой/незаданный токен оставляет соответствующий уровень ОТКРЫТЫМ
# (в логе при старте предупреждение) — для обратной совместимости.
auth:
# Защищает все /api/v1/admin/* (его использует дашборд и оператор: curl -H
# "Authorization: Bearer $TOKEN").
admin_token_env: "CONTROL_API_ADMIN_TOKEN"
# Защищает запись результатов/событий: POST /agents/{id}/self-check|events|
# results|complete и POST /probers/{site_id}/results. Один общий токен для
# validator-agent и prober. register/heartbeat/получение задания остаются
# открытыми.
agent_token_env: "CONTROL_API_AGENT_TOKEN"
orchestrator:
poll_interval_seconds: 5
self_check_timeout_seconds: 60
+4
View File
@@ -3,6 +3,10 @@
site_id: "site-1"
control_api_url: "http://control-api.internal:8080"
# Имя переменной окружения с токеном агентов control-api (тот же, что у
# validator-agent; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для
# записи результатов; register/heartbeat/получение заданий работают без него.
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
poll_interval_seconds: 5
checks:
+6
View File
@@ -3,6 +3,12 @@
validator_id: "validator_01"
control_api_url: "http://control-api.internal:8080"
# Имя переменной окружения с токеном агентов control-api (тот же, что у
# prober; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для записи
# результатов/событий; register/heartbeat/получение задания работают без него.
# Токен отправляется только в control-api — не на ip_echo_urls и не на цели
# проверок.
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
poll_interval_seconds: 5
self_check:
+13
View File
@@ -14,6 +14,19 @@ PROBER_SITE_ID=site-1
# --- validator-agent (must match a validator_id in the mounted control-api config) ---
VALIDATOR_AGENT_VALIDATOR_ID=validator_01
# --- authentication (empty = that level stays open; services log a warning) ---
# Generate each secret with: openssl rand -hex 32
# control-api admin token (protects /api/v1/admin/*) and the dashboard's copy of it:
CONTROL_API_ADMIN_TOKEN=
ADMIN_DASHBOARD_CONTROL_API_TOKEN=
# control-api agent token (protects result/event writes); the same value goes
# to prober and validator-agent:
CONTROL_API_AGENT_TOKEN=
# Dashboard login (both must be set to enable it) and cookie-signing key:
ADMIN_DASHBOARD_USERNAME=
ADMIN_DASHBOARD_PASSWORD=
ADMIN_DASHBOARD_SESSION_SECRET=
# ADMIN_DASHBOARD_CONTROL_API_URL / PROBER_CONTROL_API_URL /
# VALIDATOR_AGENT_CONTROL_API_URL default to http://control-api:8080 in
# docker-compose.yml (same Docker network) — only set here to override.
+16
View File
@@ -39,3 +39,19 @@ VALIDATOR_AGENT_CONTROL_API_URL=https://control-api.internal.example.com
# --- admin-dashboard (only needed on a dashboard-profile host) ---
ADMIN_DASHBOARD_CONTROL_API_URL=http://control-api:8080
# --- authentication (fill in only what this host needs; empty = open + warning) ---
# Generate each secret with: openssl rand -hex 32
# Rollout order without downtime: update all binaries first, then give the
# tokens to prober / validator-agent / dashboard, and set the control-api
# tokens (and restart it) last.
#
# control-plane host: tokens that control-api enforces
CONTROL_API_ADMIN_TOKEN=
CONTROL_API_AGENT_TOKEN=
# dashboard host: control-api's admin token, the login, and the cookie-signing key
ADMIN_DASHBOARD_CONTROL_API_TOKEN=
ADMIN_DASHBOARD_USERNAME=
ADMIN_DASHBOARD_PASSWORD=
ADMIN_DASHBOARD_SESSION_SECRET=
# prober / validator hosts use CONTROL_API_AGENT_TOKEN (same value as above)
+25
View File
@@ -25,6 +25,7 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name prober \
-e PROBER_TCP_TIMEOUT_SECONDS=5 \
-e PROBER_ICMP_TIMEOUT_SECONDS=5 \
-e PROBER_ICMP_COUNT=3 \
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
cloud-ip-validator-prober
Рабочий пример (стенд 62.176.10.113):
@@ -42,6 +43,9 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name prober \
PROBER_TCP_TIMEOUT_SECONDS опциональная, дефолт 5
PROBER_ICMP_TIMEOUT_SECONDS опциональная, дефолт 5
PROBER_ICMP_COUNT опциональная, дефолт 3
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api (то же
значение, что у control-api); без него результаты
проверок будут отклонены, если токен включён
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
@@ -61,6 +65,10 @@ docker run -d --platform linux/amd64 -p 8090:8090 --name admin-dashboard \
-e ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS=10 \
-e ADMIN_DASHBOARD_LAST_COMPLETED_COUNT=20 \
-e ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS=5 \
-e ADMIN_DASHBOARD_CONTROL_API_TOKEN=<admin_token> \
-e ADMIN_DASHBOARD_USERNAME=<login> \
-e ADMIN_DASHBOARD_PASSWORD=<password> \
-e ADMIN_DASHBOARD_SESSION_SECRET=<random_hex> \
cloud-ip-validator-admin-dashboard
Рабочий пример (стенд 62.176.10.113):
@@ -75,6 +83,14 @@ docker run -d --platform linux/amd64 -p 8090:8090 --name admin-dashboard \
ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS опциональная, дефолт 10
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT опциональная, дефолт 20
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS опциональная, дефолт 5
ADMIN_DASHBOARD_CONTROL_API_TOKEN опциональная: admin-токен control-api
ADMIN_DASHBOARD_USERNAME / _PASSWORD опциональные: логин дашборда; если не
заданы оба — вход не требуется (в логе
предупреждение)
ADMIN_DASHBOARD_SESSION_SECRET опциональная: ключ подписи cookie сессии;
без неё случайный, сессии сбрасываются
рестартом
(секреты генерируются так: openssl rand -hex 32)
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
@@ -102,12 +118,18 @@ docker run -d --platform linux/amd64 -p 8080:8080 --name control-api \
-e OS_PROJECT_ID=<project_id> \
-e OS_REGION_NAME=<region> \
-e OS_TOKEN=<token> \
-e CONTROL_API_ADMIN_TOKEN=<admin_token> \
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
cloud-ip-validator-control-api
(при auth_method: password — вместо OS_TOKEN передать OS_USERNAME,
OS_USER_DOMAIN_NAME, OS_PASSWORD; для openstack.mode: mock креденшлы не
нужны)
CONTROL_API_ADMIN_TOKEN закрывает /api/v1/admin/*, CONTROL_API_AGENT_TOKEN —
запись результатов/событий агентами и пробером; не заданный токен оставляет
соответствующий уровень открытым (в логе предупреждение).
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
-p 8080:8080 проброс порта control-api наружу
@@ -132,6 +154,7 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
-e VALIDATOR_AGENT_ICMP_COUNT=3 \
-e VALIDATOR_AGENT_SSH_ENABLED=false \
-e VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5 \
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
cloud-ip-validator-validator-agent
Переменные окружения:
@@ -145,6 +168,8 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name validator-agent \
VALIDATOR_AGENT_ICMP_COUNT опциональная, дефолт 3
VALIDATOR_AGENT_SSH_ENABLED опциональная, дефолт false
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS опциональная, дефолт 5
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api
(то же значение, что у control-api)
Примечания:
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
@@ -8,6 +8,10 @@ export ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS="${ADMIN_DASHBOARD_CONTROL_AP
export ADMIN_DASHBOARD_LAST_COMPLETED_COUNT="${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
export ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS="${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
# NOT templated into the YAML: the binary reads them straight from this
# container's environment (names default in the config loader), so secrets
# never land in a file inside the container.
envsubst '${ADMIN_DASHBOARD_LISTEN_ADDR} ${ADMIN_DASHBOARD_CONTROL_API_URL} ${ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS} ${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT} ${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS}' \
< /etc/admin-dashboard/admin-dashboard.yaml.tmpl > /etc/admin-dashboard/admin-dashboard.yaml
@@ -17,6 +17,13 @@ database:
openstack:
mode: "mock"
# Статические bearer-токены читаются из переменных окружения контейнера с
# этими именами (задаются в .env, см. deploy/docker/.env.example). Пусто =
# соответствующий уровень API открыт (с предупреждением в логе).
auth:
admin_token_env: "CONTROL_API_ADMIN_TOKEN" # защищает /api/v1/admin/*
agent_token_env: "CONTROL_API_AGENT_TOKEN" # защищает запись результатов агентами/пробером
orchestrator:
poll_interval_seconds: 5
self_check_timeout_seconds: 60
+15
View File
@@ -34,6 +34,11 @@ services:
networks: [backend]
volumes:
- control-api-db:/var/lib/cloud-ip-validator
environment:
# Static bearer tokens (empty = that API level stays open, with a
# warning in the log). Generate with `openssl rand -hex 32`.
CONTROL_API_ADMIN_TOKEN: "${CONTROL_API_ADMIN_TOKEN:-}"
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
interval: 5s
@@ -55,6 +60,12 @@ services:
ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS: "${ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS:-10}"
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT: "${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS: "${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
# Admin token of control-api, and the dashboard's own login. Empty
# username/password = login disabled (warning in the log).
ADMIN_DASHBOARD_CONTROL_API_TOKEN: "${ADMIN_DASHBOARD_CONTROL_API_TOKEN:-}"
ADMIN_DASHBOARD_USERNAME: "${ADMIN_DASHBOARD_USERNAME:-}"
ADMIN_DASHBOARD_PASSWORD: "${ADMIN_DASHBOARD_PASSWORD:-}"
ADMIN_DASHBOARD_SESSION_SECRET: "${ADMIN_DASHBOARD_SESSION_SECRET:-}"
prober:
build:
@@ -72,6 +83,8 @@ services:
PROBER_TCP_TIMEOUT_SECONDS: "${PROBER_TCP_TIMEOUT_SECONDS:-5}"
PROBER_ICMP_TIMEOUT_SECONDS: "${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
PROBER_ICMP_COUNT: "${PROBER_ICMP_COUNT:-3}"
# Agent token of control-api (same value as on control-api).
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
validator-agent:
build:
@@ -92,3 +105,5 @@ services:
VALIDATOR_AGENT_ICMP_COUNT: "${VALIDATOR_AGENT_ICMP_COUNT:-3}"
VALIDATOR_AGENT_SSH_ENABLED: "${VALIDATOR_AGENT_SSH_ENABLED:-false}"
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
# Agent token of control-api (same value as on control-api).
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
@@ -9,6 +9,10 @@ export PROBER_TCP_TIMEOUT_SECONDS="${PROBER_TCP_TIMEOUT_SECONDS:-5}"
export PROBER_ICMP_TIMEOUT_SECONDS="${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
export PROBER_ICMP_COUNT="${PROBER_ICMP_COUNT:-3}"
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
# NOT templated into the YAML: the binary reads them straight from this
# container's environment (names default in the config loader), so secrets
# never land in a file inside the container.
envsubst '${PROBER_SITE_ID} ${PROBER_CONTROL_API_URL} ${PROBER_POLL_INTERVAL_SECONDS} ${PROBER_TCP_TIMEOUT_SECONDS} ${PROBER_ICMP_TIMEOUT_SECONDS} ${PROBER_ICMP_COUNT}' \
< /etc/prober/prober.yaml.tmpl > /etc/prober/prober.yaml
@@ -12,6 +12,10 @@ export VALIDATOR_AGENT_ICMP_COUNT="${VALIDATOR_AGENT_ICMP_COUNT:-3}"
export VALIDATOR_AGENT_SSH_ENABLED="${VALIDATOR_AGENT_SSH_ENABLED:-false}"
export VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS="${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
# Tokens/passwords (CONTROL_API_AGENT_TOKEN, ADMIN_DASHBOARD_*) are deliberately
# NOT templated into the YAML: the binary reads them straight from this
# container's environment (names default in the config loader), so secrets
# never land in a file inside the container.
envsubst '${VALIDATOR_AGENT_VALIDATOR_ID} ${VALIDATOR_AGENT_CONTROL_API_URL} ${VALIDATOR_AGENT_POLL_INTERVAL_SECONDS} ${VALIDATOR_AGENT_SELF_CHECK_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_HTTPS_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_TIMEOUT_SECONDS} ${VALIDATOR_AGENT_ICMP_COUNT} ${VALIDATOR_AGENT_SSH_ENABLED} ${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS}' \
< /etc/validator-agent/validator-agent.yaml.tmpl > /etc/validator-agent/validator-agent.yaml
+5
View File
@@ -8,6 +8,11 @@ Type=simple
User=cloud-ip-validator
Group=cloud-ip-validator
ExecStart=/usr/local/bin/admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
# Optional (leading "-": the file may be absent). Holds
# ADMIN_DASHBOARD_CONTROL_API_TOKEN (control-api's admin token) and the
# dashboard login: ADMIN_DASHBOARD_USERNAME, ADMIN_DASHBOARD_PASSWORD,
# ADMIN_DASHBOARD_SESSION_SECRET. Keep it mode 0600, owned by the service user.
EnvironmentFile=-/etc/cloud-ip-validator/admin-dashboard.env
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
+3 -1
View File
@@ -13,7 +13,9 @@ ExecStart=/usr/local/bin/control-api -config /etc/cloud-ip-validator/control-api
# (auth_method: token) or OS_USERNAME / OS_USER_DOMAIN_NAME / OS_PASSWORD
# (auth_method: password) — see docs/SETUP.md. Keep this file mode 0600,
# owned by the service user; never commit it or put credentials in the
# YAML config.
# YAML config. Also holds CONTROL_API_ADMIN_TOKEN and CONTROL_API_AGENT_TOKEN
# (generate each with `openssl rand -hex 32`); when unset, the matching API
# level stays open and control-api logs a warning.
EnvironmentFile=/etc/cloud-ip-validator/control-api.env
WorkingDirectory=/var/lib/cloud-ip-validator
Restart=on-failure
+4
View File
@@ -8,6 +8,10 @@ Type=simple
User=cloud-ip-validator
Group=cloud-ip-validator
ExecStart=/usr/local/bin/prober -config /etc/cloud-ip-validator/prober.yaml
# Optional (leading "-": the file may be absent). Holds CONTROL_API_AGENT_TOKEN,
# the same value as on control-api. Keep it mode 0600, owned by the service
# user.
EnvironmentFile=-/etc/cloud-ip-validator/prober.env
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
+4
View File
@@ -8,6 +8,10 @@ Type=simple
User=cloud-ip-validator
Group=cloud-ip-validator
ExecStart=/usr/local/bin/validator-agent -config /etc/cloud-ip-validator/validator-agent.yaml
# Optional (leading "-": the file may be absent). Holds CONTROL_API_AGENT_TOKEN,
# the same value as on control-api. Keep it mode 0600, owned by the service
# user.
EnvironmentFile=-/etc/cloud-ip-validator/validator-agent.env
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
+33 -9
View File
@@ -4,15 +4,13 @@ Control API — единственная точка входа в систему
`prober` и оператора (администратора). Все данные передаются в формате
JSON, базовый префикс прикладных методов — `/api/v1`.
> **Важно.** На данный момент API не защищён аутентификацией/авторизацией
> — эндпоинты доступны любому, кто может достучаться до порта control-api
> по сети. Это касается и методов из раздела
> [«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией)
> ниже — они меняют, что и как проверяется, без подтверждения личности
> вызывающего. Для эксплуатации за пределами доверенного сегмента сети
> обязательно ограничьте доступ на уровне сети/файрвола (см.
> [SETUP.md](SETUP.md#сетевые-доступы)). Добавление bearer-токена — известное
> направление доработки, в текущей версии не реализовано.
> **Аутентификация.** Доступ к API определяется двумя статическими
> bearer-токенами — см. [«Аутентификация»](#аутентификация). Токен задаётся
> переменной окружения; **если токен не задан, соответствующий уровень остаётся
> открытым** (control-api стартует с предупреждением в логе) — так сделано для
> обратной совместимости. Поэтому для эксплуатации за пределами доверенного
> сегмента сети токены нужно задать, а доступ дополнительно ограничить на уровне
> сети/файрвола (см. [SETUP.md](SETUP.md#сетевые-доступы)).
Базовый URL в примерах — `http://control-api.internal:8080`, замените на
адрес вашего стенда (см. `server.listen_addr` в конфиге control-api).
@@ -23,6 +21,7 @@ JSON, базовый префикс прикладных методов — `/ap
## Содержание
- [Аутентификация](#аутентификация)
- [Общие соглашения](#общие-соглашения)
- [Методы для validator-agent](#методы-для-validator-agent)
- [Методы для prober](#методы-для-prober)
@@ -33,6 +32,31 @@ JSON, базовый префикс прикладных методов — `/ap
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
- [Сквозной пример работы (curl)](#сквозной-пример-работы-curl)
## Аутентификация
Токен передаётся заголовком `Authorization: Bearer <токен>`. Токены статические, **без срока жизни**; ротация — смена
переменной окружения и перезапуск. Сравнение выполняется в константное время.
| Уровень | Токен (переменная на control-api) | Какие методы |
|---|---|---|
| **admin** | `CONTROL_API_ADMIN_TOKEN` | все `/api/v1/admin/*` (очередь, реестр, автоцикл, `config/*`) |
| **agent** | `CONTROL_API_AGENT_TOKEN` | запись результатов: `POST /agents/{id}/self-check`, `/events`, `/results`, `/complete` и `POST /probers/{site_id}/results` |
| **открыто** | — | `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` |
- Токены разные: токен администратора **не** подходит для методов агентов, и наоборот.
- Валидатор и пробер могут без токена зарегистрироваться, слать heartbeat и забирать задание (настройку); отправка результатов без токена агентов — `401`.
- Имена переменных меняются в секции `auth` конфига control-api (`admin_token_env`, `agent_token_env`); сами значения в YAML не хранятся.
- Ответ при отказе: `401 {"error": "unauthorized"}` с заголовком `WWW-Authenticate: Bearer`.
- Токен не задан (пустая переменная) — уровень открыт; в логе control-api при старте предупреждение. Токены нужно генерировать случайными: `openssl rand -hex 32`.
- Токены уходят открытым текстом, если TLS не терминируется перед control-api, — публикуйте API через reverse-proxy с TLS.
```bash
export ADMIN_TOKEN=... # значение CONTROL_API_ADMIN_TOKEN
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://<control-api>:8080/api/v1/admin/status
```
> Во всех примерах `curl` ниже заголовок `Authorization` для краткости опущен; если токен администратора задан, добавляйте его к методам `/api/v1/admin/*`.
## Общие соглашения
- Тело запроса и ответа — JSON (`Content-Type: application/json`).
+25
View File
@@ -157,6 +157,26 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
циклов) остаётся всегда. Подробнее —
[API.md](API.md#реестр-адресов-и-история-проверок).
## Вход и сессия
Если заданы `ADMIN_DASHBOARD_USERNAME` и `ADMIN_DASHBOARD_PASSWORD`, все страницы, кроме `/login` и `/static/*`, требуют входа.
Не заданы — дашборд открыт, в логе предупреждение `dashboard login is disabled`.
- **Вход:** страница `/login` (логин и пароль единственного администратора). Неверная пара — «Неверный логин или пароль», cookie не выдаётся.
Без сессии обычный запрос получает редирект `303` на `/login?next=…` (после входа — возврат на исходную страницу; `next` принимается только как
относительный путь на этом же сайте).
- **Сессия** хранится в cookie `session` (подпись HMAC-SHA256, `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS), состояния на сервере нет —
дашборд остаётся stateless. Срок — `auth.session_ttl_minutes` (по умолчанию 480 минут). Кнопка «Выйти» (внизу сайдбара) стирает cookie в браузере;
скопированная cookie остаётся валидной до истечения срока. Сбросить все сессии сразу — сменить `ADMIN_DASHBOARD_SESSION_SECRET` и перезапустить дашборд.
- **Фоновое обновление.** Когда сессия истекла, htmx-запросы (опрос `/overview/fragment`) получают `401` с `HX-Redirect: /login` — браузер
уходит на страницу входа целиком, а не подставляет её внутрь фрагмента.
- **CSRF:** изменяющие запросы (`POST`/`PUT`/`DELETE`) принимаются, только если `Origin` (или `Referer`) совпадает с хостом дашборда;
токены в формах не нужны. Reverse-proxy, подменяющий заголовок `Host`, получит `403` на изменяющие запросы.
- **Перебор пароля:** 5 неудачных попыток входа с одного IP за 10 минут → `429` с `Retry-After`; пока действует блокировка, отклоняется и верный пароль.
Счётчик считает по адресу TCP-соединения и не доверяет `X-Forwarded-For`, поэтому за reverse-proxy все клиенты окажутся в одной корзине.
- **Токен к control-api.** Дашборд обращается к API с токеном администратора (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`); если он неверен, страницы
показывают баннер с ответом `401` от control-api.
## Конфигурация
См. `configs/admin-dashboard.example.yaml`. Ключевые поля:
@@ -168,6 +188,11 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
на странице обзора.
- `overview.poll_interval_seconds` — как часто браузер опрашивает
`/overview/fragment` для live-обновления.
- `control_api.token_env` — имя переменной окружения с токеном администратора control-api
(по умолчанию `ADMIN_DASHBOARD_CONTROL_API_TOKEN`).
- `auth.username_env`, `auth.password_env`, `auth.session_secret_env` — имена переменных с логином, паролем и ключом подписи сессии
(по умолчанию `ADMIN_DASHBOARD_USERNAME`, `ADMIN_DASHBOARD_PASSWORD`, `ADMIN_DASHBOARD_SESSION_SECRET`); `auth.session_ttl_minutes` — срок сессии
(480). Подробности — [«Вход и сессия»](#вход-и-сессия).
## Отображение ошибок
+29
View File
@@ -287,6 +287,35 @@ cp configs/prober.example.yaml /etc/cloud-ip-validator/prober.yaml
- `control_api_url` — адрес control-api, доступный с площадки (обычно
через интернет — площадки внешние).
### 5. Аутентификация: токены и пароль дашборда
Доступ к API и дашборду защищается секретами из переменных окружения (в YAML значения не хранятся; в `*.yaml` — только *имена*
переменных, и менять их нужно редко). Токены генерируются случайными: `openssl rand -hex 32`. Схема доступа к методам —
[API.md](API.md#аутентификация).
| Где | Переменная | Назначение |
|---|---|---|
| control-api | `CONTROL_API_ADMIN_TOKEN` | токен администратора: закрывает `/api/v1/admin/*` |
| control-api | `CONTROL_API_AGENT_TOKEN` | токен агентов: закрывает запись результатов валидаторов и проберов |
| validator-agent, prober | `CONTROL_API_AGENT_TOKEN` | тот же токен агентов (отправляется как Bearer) |
| admin-dashboard | `ADMIN_DASHBOARD_CONTROL_API_TOKEN` | токен администратора control-api (то же значение, что `CONTROL_API_ADMIN_TOKEN`) |
| admin-dashboard | `ADMIN_DASHBOARD_USERNAME`, `ADMIN_DASHBOARD_PASSWORD` | логин и пароль единственного администратора дашборда |
| admin-dashboard | `ADMIN_DASHBOARD_SESSION_SECRET` | ключ подписи cookie-сессии (случайная строка; без него — случайный на каждый запуск, сессии сбрасываются рестартом) |
- **Пустое значение = защита выключена.** Токен не задан — соответствующий уровень API открыт; логин/пароль не заданы — дашборд открыт. В обоих
случаях в логе при старте — предупреждение. Это сделано для обратной совместимости; на реальном стенде задайте всё.
- systemd: добавьте переменные в `/etc/cloud-ip-validator/<компонент>.env` (подключается `EnvironmentFile=`, файл `chmod 600`). Docker: переменные
из `.env` (см. `.env.example`).
- Ключи `auth.*` и `*_token_env` в YAML меняют только имена переменных; время жизни сессии — `auth.session_ttl_minutes` дашборда (по умолчанию 480).
- Токены и пароль передаются открытым текстом, если перед сервисами нет TLS: публикуйте API и дашборд через reverse-proxy с TLS.
**Порядок включения без простоя** (особенно когда валидаторы и пробер на других машинах):
1. обновите бинарники всех компонентов — токены ещё не заданы, всё работает как раньше;
2. задайте `CONTROL_API_AGENT_TOKEN` на валидаторах и проберах, `ADMIN_DASHBOARD_*` на дашборде и перезапустите их;
3. **последним** задайте `CONTROL_API_ADMIN_TOKEN` и `CONTROL_API_AGENT_TOKEN` на control-api и перезапустите его.
Если включить токен агентов на control-api раньше, чем он появится у валидатора или пробера, их результаты будут получать `401` и проверки не завершатся.
Ротация токена — та же последовательность с новым значением.
## Развёртывание control-api
```bash
+6
View File
@@ -7,6 +7,12 @@
[API.md](API.md) — здесь мы используем их только как инструмент, не
углубляясь в протокол.
> **Токен в примерах.** Если на стенде включена аутентификация
> ([SETUP.md](SETUP.md#5-аутентификация-токены-и-пароль-дашборда)), к вызовам
> `/api/v1/admin/*` в примерах `curl` ниже нужно добавлять заголовок
> `-H "Authorization: Bearer $ADMIN_TOKEN"` (значение `CONTROL_API_ADMIN_TOKEN`);
> для краткости он опущен. Дашборд запрашивает логин и пароль.
## Содержание
- [Как устроена работа с системой](#как-устроена-работа-с-системой)
@@ -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`: сервисы не теряют регистрацию в момент включения токенов.
@@ -0,0 +1,71 @@
# Ревью и тестирование: аутентификация API и UI
> Дата: 2026-10-01 11:31 MSK · План: [2026-10-01_11-12_authentication-plan.md](2026-10-01_11-12_authentication-plan.md)
> Статус: **реализовано и проверено на живом окружении**; токен агентов на живом стенде **намеренно не включён** (см. «Состояние живого стенда»).
## Итог
Реализовано по плану: ручки управления закрыты токеном администратора, записывающие вызовы валидатора и пробера — отдельным
бессрочным токеном агентов, получение настройки и задания остаётся открытым, дашборд закрыт логином и паролем (сессия в подписанной cookie).
Код написан отдельным агентом (Sonnet 5.5), ревью и все проверки выполнены независимо (Sonnet 5.5, high).
Найден и исправлен один реальный дефект (вёрстка страницы входа); остальные замечания — ограничения дизайна, перечислены ниже.
## Что реализовано
| Область | Изменения |
|---|---|
| control-api | `internal/httpapi/routes.go` — таблица маршрутов `{pattern, handler, access}` с **обязательным** уровнем доступа (33 admin, 5 agent, 7 open); `internal/httpapi/auth.go` — `Authenticator`, Bearer, сравнение `sha256` + `subtle.ConstantTimeCompare`, `401` + `WWW-Authenticate: Bearer`, отказы пишутся в лог без токена; `Server.WithAuth`; `config.auth.{admin_token_env,agent_token_env}` |
| validator-agent, prober | `apiclient.Client.Token` — Bearer на каждом запросе к control-api; `WithToken` в `agentcore`/`probercore`; `control_api_token_env`. Токен не попадает в IP-echo и проверки |
| admin-dashboard | `internal/dashboard/auth.go` — вход по логину/паролю, сессия без состояния (HMAC-SHA256), CSRF по `Origin`/`Referer`, защита от перебора, `HX-Redirect` для htmx, защита от open-redirect в `next`; `templates/login.html`; «Выйти» и имя пользователя в сайдбаре; Bearer к control-api в `client.do`; индикатор — только при включённой аутентификации |
| Развёртывание | переменные в `deploy/docker/*`, `rxprod-compose/docker-compose.yml`, `.env*.example`, `RUN.txt`; `EnvironmentFile=` в 4 systemd-юнитах; ключи в `configs/*.example.yaml` и копиях `rxprod-compose/sources/` |
| Тесты | `internal/httpapi/auth_test.go` (матрица по таблице маршрутов), `internal/apiclient/apiclient_test.go`, токены в `agentcore`/`probercore`, `internal/dashboard/auth_test.go` (16 тестов); `scripts/run-local-e2e.sh` — токены и явные проверки 401/open |
Новых зависимостей нет (`crypto/*` стандартной библиотеки). Режим по умолчанию — обратная совместимость: пустой токен ⇒ соответствующий уровень открыт, в логе предупреждение.
## Результаты проверок
| Проверка | Результат |
|---|---|
| `gofmt`, `go build ./...`, `go vet ./...` | чисто |
| `go test ./...` | все пакеты зелёные |
| `go test -race` (httpapi, dashboard, apiclient, agentcore, probercore) | зелёные |
| `scripts/run-local-e2e.sh` с токенами | exit 0: 5 проверок доступа ok, реальные агент и пробер с токеном довели адрес до `pass`, автоцикл ok |
| Живые контейнеры `cloud-ip-validator-*`, `curl` | **40/40** (control-api 401/200, открытые маршруты, heartbeat внешних валидаторов продолжается; дашборд: редиректы, `HX-Redirect`, CSRF, cookie-флаги, open-redirect, tampered cookie, выход) |
| Живой дашборд в настоящем Chromium (Playwright) | **15/15**: вход/ошибка пароля, фоновый htmx-опрос с сессией, htmx `PUT /settings` и `PUT /settings/auto-cycle` (Origin-проверка проходит), выход, нет JS-ошибок |
| Изолированный стенд `civ-authtest-*` (mock, отдельная сеть, удалён после прогона) | токен агентов: `401` без токена / с токеном администратора / с чужим, принят с токеном агентов; реальный агент **без токена** регистрируется и шлёт heartbeat (открытые маршруты), а его записи отклоняются (41 отказ в логе); **с токеном** новых отказов нет, self-check уходит; пробер с токеном регистрируется; перебор: 6-я неверная попытка → `429`, `Retry-After` 600 с, верный пароль в блокировке тоже `429`; секреты в логах не найдены |
Примечание: 4 «FAIL» в выводе скрипта изолированного стенда — ошибка форматирования самого скрипта (сравнивалось `yes` со строкой `yes (…)`);
значения в скобках подтверждают успех (HTTP 200, 41 отказ, 3 IP, `Retry-After` 600). Реальных провалов нет.
## Замечания ревью
| № | Серьёзность | Замечание | Статус |
|---|---|---|---|
| 1 | средняя | **Страница входа: сломана вёрстка** — `.field` имеет `flex: 1 1 220px`, в колонке это давало 220px пустоты между полями; поле пароля не попадало в правило стилей `input[type=…]` и выглядело нестилизованным. Найдено по скриншоту на живом стенде | **исправлено** (`dashboard.css`: `input[type="password"]` в общее правило, `.login-card .field { flex: 0 0 auto }`), перепроверено скриншотом |
| 2 | средняя | Счётчик перебора ключуется по `RemoteAddr` и **не доверяет** `X-Forwarded-For`. За reverse-proxy все клиенты разделят одну корзину: 5 неверных попыток заблокируют вход всем (DoS на админа). Сейчас не проявляется — дашборд открыт напрямую на `:8091`, Caddy перед ним нет | принято; при публикации через прокси нужен список доверенных прокси |
| 3 | низкая | Сессия без состояния: «Выйти» стирает cookie в браузере, но украденная копия остаётся валидной до истечения (`session_ttl_minutes`); смена пароля сессии не отзывает. Инвалидация всех сессий — смена `ADMIN_DASHBOARD_SESSION_SECRET` и рестарт | принято, описано в документации |
| 4 | низкая | На защищённых страницах нет `Cache-Control: no-store` (кнопка «Назад» после выхода может показать кэш) | не исправлено |
| 5 | низкая | `register` и `heartbeat` открыты (решение пользователя): подделка heartbeat «оживляет» упавший валидатор. Перенос под токен агентов — одна строка в таблице маршрутов | принято по решению пользователя |
| 6 | низкая | Токены и пароль передаются по HTTP открытым текстом, если TLS не терминируется перед сервисами | описано в документации (TLS на reverse-proxy) |
| 7 | инфо | `withAuthInfo` заполняет `PageData` через `reflect` — работает, но хрупко при смене структур | не блокирует |
| 8 | инфо | Не добавлен вариант `TestEndToEndHTTPFlow` с токенами — покрыто матрицей по таблице маршрутов и e2e-скриптом | принято |
| 9 | инфо | CSRF-проверка сверяет `Origin` с `Host`: прокси, подменяющий `Host`, получит `403` на POST/PUT | описано в документации |
## Состояние живого стенда (`rxprod-compose`, проект `cloud-ip-validator`)
- **Включено:** токен администратора на control-api (`CONTROL_API_ADMIN_TOKEN`), тот же токен у дашборда (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`),
вход в дашборд (`ADMIN_DASHBOARD_USERNAME` / `ADMIN_DASHBOARD_PASSWORD`), ключ сессии (`ADMIN_DASHBOARD_SESSION_SECRET`).
Значения — в `rxprod-compose/.env` (gitignored; права ужесточены до `600`). Дашборд: `http://<хост>:8091/`, логин `admin`.
- **Не включено намеренно:** `CONTROL_API_AGENT_TOKEN`. На стенде работают внешние компоненты со старыми бинарниками — валидаторы
`validator-1`/`validator-2` (облачные ВМ) и внешний пробер `rxyc`: после включения токена агентов их результаты начнут получать `401`.
Пока записывающие вызовы агентов открыты (в логе control-api — предупреждение `agent write API is open`).
- **Раскатка токена агентов:** (1) обновить бинарники на валидаторах и внешних проберах; (2) задать им `CONTROL_API_AGENT_TOKEN`
(`openssl rand -hex 32`) и перезапустить; (3) последним добавить тот же токен в `.env` стенда (control-api и prober) и выполнить `docker compose up -d`.
- Образы `civ-capi`, `civ-adash`, `civ-prober`, `civ-agent` пересобраны; предыдущие сохранены под тегом `:pre-auth`
(откат: `docker tag civ-capi:pre-auth civ-capi:latest` и `docker compose up -d`). Бэкап БД и прежнего `.env` — в каталоге scratchpad сессии (`/tmp`, временный).
- `bin/` пересобран (`CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"`), `bin/SHA256SUMS` обновлён.
## Что осталось
- Раскатка токена агентов на внешние компоненты (см. выше).
- По желанию: `Cache-Control: no-store` (замечание 4), список доверенных прокси для счётчика перебора (замечание 2).
+7
View File
@@ -53,6 +53,13 @@ func New(cfg *config.ValidatorAgent, log *slog.Logger) *Agent {
}
}
// WithToken sets the bearer token sent to the Control API (and only to it:
// the IP-echo lookup and all check traffic use separate clients).
func (a *Agent) WithToken(token string) *Agent {
a.client.Token = token
return a
}
// Run registers with the Control API and polls forever until ctx is
// cancelled.
func (a *Agent) Run(ctx context.Context) error {
+31
View File
@@ -98,3 +98,34 @@ func TestRegisterWithRetryStopsOnCancel(t *testing.T) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}
// TestTokenGoesOnlyToControlAPI: the bearer token authenticates calls to
// control-api, and must never leak to the external IP-echo service.
func TestTokenGoesOnlyToControlAPI(t *testing.T) {
var apiAuth, echoAuth atomic.Value
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiAuth.Store(r.Header.Get("Authorization"))
w.Write([]byte(`{"ok":true,"poll_interval_seconds":5}`))
}))
defer api.Close()
echo := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
echoAuth.Store(r.Header.Get("Authorization"))
w.Write([]byte("203.0.113.7"))
}))
defer echo.Close()
a := New(&config.ValidatorAgent{ValidatorID: "val-1", ControlAPIURL: api.URL}, testLogger()).WithToken("agent-secret")
ctx := context.Background()
if err := a.registerWithRetry(ctx); err != nil {
t.Fatalf("register: %v", err)
}
if _, err := fetchIPEcho(ctx, echo.URL); err != nil {
t.Fatalf("fetchIPEcho: %v", err)
}
if got, _ := apiAuth.Load().(string); got != "Bearer agent-secret" {
t.Fatalf("control-api Authorization = %q, want bearer token", got)
}
if got, _ := echoAuth.Load().(string); got != "" {
t.Fatalf("IP-echo request carried Authorization %q, want none", got)
}
}
+6
View File
@@ -17,6 +17,9 @@ import (
type Client struct {
BaseURL string
HTTPClient *http.Client
// Token, when non-empty, is sent as "Authorization: Bearer <Token>" on
// every request to the Control API.
Token string
}
func New(baseURL string, timeout time.Duration) *Client {
@@ -42,6 +45,9 @@ func (c *Client) Do(ctx context.Context, method, path string, body, out interfac
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
+30
View File
@@ -0,0 +1,30 @@
package apiclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestDoSetsBearerOnlyWhenTokenSet(t *testing.T) {
var got []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("Authorization"))
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
c := New(ts.URL, 5*time.Second)
if _, err := c.Do(context.Background(), http.MethodGet, "/x", nil, nil); err != nil {
t.Fatalf("do without token: %v", err)
}
c.Token = "secret-agent-token"
if _, err := c.Do(context.Background(), http.MethodPost, "/x", map[string]string{"a": "b"}, nil); err != nil {
t.Fatalf("do with token: %v", err)
}
if len(got) != 2 || got[0] != "" || got[1] != "Bearer secret-agent-token" {
t.Fatalf("Authorization headers = %q, want [\"\" \"Bearer secret-agent-token\"]", got)
}
}
+60 -4
View File
@@ -25,6 +25,15 @@ type ControlAPI struct {
Targets map[string][]string `yaml:"targets"`
Inbound InboundConfig `yaml:"inbound_checks"`
IPAddresses []string `yaml:"ip_addresses"`
Auth ControlAPIAuth `yaml:"auth"`
}
// ControlAPIAuth names the environment variables control-api reads its two
// static bearer tokens from. Values are never stored in the config. An unset
// (empty) token leaves that access level open, with a startup warning.
type ControlAPIAuth struct {
AdminTokenEnv string `yaml:"admin_token_env"` // default CONTROL_API_ADMIN_TOKEN — protects /api/v1/admin/*
AgentTokenEnv string `yaml:"agent_token_env"` // default CONTROL_API_AGENT_TOKEN — protects agent/prober write calls
}
type ServerConfig struct {
@@ -155,6 +164,12 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
if c.OpenStack.PasswordEnv == "" {
c.OpenStack.PasswordEnv = "OS_PASSWORD"
}
if c.Auth.AdminTokenEnv == "" {
c.Auth.AdminTokenEnv = "CONTROL_API_ADMIN_TOKEN"
}
if c.Auth.AgentTokenEnv == "" {
c.Auth.AgentTokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.Orchestrator.PollIntervalSeconds == 0 {
c.Orchestrator.PollIntervalSeconds = 5
}
@@ -182,8 +197,11 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
// ---- validator-agent ----
type ValidatorAgent struct {
ValidatorID string `yaml:"validator_id"`
ControlAPIURL string `yaml:"control_api_url"`
ValidatorID string `yaml:"validator_id"`
ControlAPIURL string `yaml:"control_api_url"`
// ControlAPITokenEnv is the name of the env var holding the agent bearer
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
ControlAPITokenEnv string `yaml:"control_api_token_env"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
SelfCheck SelfCheckCfg `yaml:"self_check"`
Checks AgentChecks `yaml:"checks"`
@@ -223,6 +241,9 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
if c.PollIntervalSeconds == 0 {
c.PollIntervalSeconds = 5
}
if c.ControlAPITokenEnv == "" {
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.SelfCheck.TimeoutSeconds == 0 {
c.SelfCheck.TimeoutSeconds = 10
}
@@ -253,8 +274,11 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
// ---- prober ----
type Prober struct {
SiteID string `yaml:"site_id"`
ControlAPIURL string `yaml:"control_api_url"`
SiteID string `yaml:"site_id"`
ControlAPIURL string `yaml:"control_api_url"`
// ControlAPITokenEnv is the name of the env var holding the agent bearer
// token (default CONTROL_API_AGENT_TOKEN). Empty value = no token sent.
ControlAPITokenEnv string `yaml:"control_api_token_env"`
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
Checks ProberChecks `yaml:"checks"`
}
@@ -273,6 +297,9 @@ func LoadProber(path string) (*Prober, error) {
if c.PollIntervalSeconds == 0 {
c.PollIntervalSeconds = 5
}
if c.ControlAPITokenEnv == "" {
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
}
if c.Checks.TCPTimeoutSeconds == 0 {
c.Checks.TCPTimeoutSeconds = 5
}
@@ -300,11 +327,25 @@ type AdminDashboard struct {
Server ServerConfig `yaml:"server"`
ControlAPI DashboardControlAPIConfig `yaml:"control_api"`
Overview DashboardOverviewConfig `yaml:"overview"`
Auth DashboardAuthConfig `yaml:"auth"`
}
// DashboardAuthConfig names the env vars holding the single administrator's
// login, password and the session-cookie HMAC key. If username or password
// is empty at runtime, login is not required (with a startup warning).
type DashboardAuthConfig struct {
UsernameEnv string `yaml:"username_env"` // default ADMIN_DASHBOARD_USERNAME
PasswordEnv string `yaml:"password_env"` // default ADMIN_DASHBOARD_PASSWORD
SessionSecretEnv string `yaml:"session_secret_env"` // default ADMIN_DASHBOARD_SESSION_SECRET
SessionTTLMinutes int `yaml:"session_ttl_minutes"` // default 480
}
type DashboardControlAPIConfig struct {
BaseURL string `yaml:"base_url"`
TimeoutSeconds int `yaml:"timeout_seconds"`
// TokenEnv is the name of the env var holding control-api's admin bearer
// token (default ADMIN_DASHBOARD_CONTROL_API_TOKEN).
TokenEnv string `yaml:"token_env"`
}
// DashboardOverviewConfig configures the overview page's "текущая
@@ -331,6 +372,21 @@ func LoadAdminDashboard(path string) (*AdminDashboard, error) {
if c.ControlAPI.TimeoutSeconds == 0 {
c.ControlAPI.TimeoutSeconds = 10
}
if c.ControlAPI.TokenEnv == "" {
c.ControlAPI.TokenEnv = "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
}
if c.Auth.UsernameEnv == "" {
c.Auth.UsernameEnv = "ADMIN_DASHBOARD_USERNAME"
}
if c.Auth.PasswordEnv == "" {
c.Auth.PasswordEnv = "ADMIN_DASHBOARD_PASSWORD"
}
if c.Auth.SessionSecretEnv == "" {
c.Auth.SessionSecretEnv = "ADMIN_DASHBOARD_SESSION_SECRET"
}
if c.Auth.SessionTTLMinutes == 0 {
c.Auth.SessionTTLMinutes = 480
}
if c.Overview.LastCompletedCount == 0 {
c.Overview.LastCompletedCount = 20
}
+392
View File
@@ -0,0 +1,392 @@
package dashboard
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"log/slog"
"net"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"sync"
"time"
)
const (
sessionCookieName = "session"
defaultSessionTTL = 8 * time.Hour
// Brute-force throttle: maxLoginFailures failures from one client IP
// within loginFailureWindow lock that IP out until the oldest failure
// leaves the window.
maxLoginFailures = 5
loginFailureWindow = 10 * time.Minute
)
// authState holds everything the login/session machinery needs. A nil-safe
// zero value is never used: Server.auth is always set by New, with
// enabled=false when no credentials were configured.
type authState struct {
enabled bool
user [sha256.Size]byte // sha256(username)
pass [sha256.Size]byte // sha256(password)
key []byte // HMAC key for session cookies
ttl time.Duration
now func() time.Time
throttle *loginThrottle
}
func newAuthState(cfg Config, log *slog.Logger) *authState {
a := &authState{now: time.Now, throttle: newLoginThrottle(), ttl: cfg.SessionTTL}
if a.ttl <= 0 {
a.ttl = defaultSessionTTL
}
if cfg.Username == "" || cfg.Password == "" {
log.Warn("dashboard login is disabled: username/password are not set, anyone who can reach this port has full access")
return a
}
a.enabled = true
a.user = sha256.Sum256([]byte(cfg.Username))
a.pass = sha256.Sum256([]byte(cfg.Password))
if cfg.SessionSecret != "" {
a.key = []byte(cfg.SessionSecret)
} else {
a.key = make([]byte, 32)
if _, err := rand.Read(a.key); err != nil {
panic("dashboard: crypto/rand failed: " + err.Error())
}
log.Warn("dashboard session secret is not set: using a random one, sessions are reset on every restart")
}
return a
}
// checkCredentials compares both fields in constant time and always
// evaluates both, so neither the length nor which field was wrong leaks.
func (a *authState) checkCredentials(user, pass string) bool {
u := sha256.Sum256([]byte(user))
p := sha256.Sum256([]byte(pass))
uOK := subtle.ConstantTimeCompare(u[:], a.user[:])
pOK := subtle.ConstantTimeCompare(p[:], a.pass[:])
return uOK&pOK == 1
}
// ---- session cookie ----
type sessionPayload struct {
User string `json:"u"`
Exp int64 `json:"exp"`
}
func (a *authState) sign(msg string) []byte {
m := hmac.New(sha256.New, a.key)
m.Write([]byte(msg))
return m.Sum(nil)
}
func (a *authState) issue(user string) (value string, exp time.Time) {
exp = a.now().Add(a.ttl)
raw, _ := json.Marshal(sessionPayload{User: user, Exp: exp.Unix()})
p := base64.RawURLEncoding.EncodeToString(raw)
return p + "." + base64.RawURLEncoding.EncodeToString(a.sign(p)), exp
}
// verify returns the session's user if value is an untampered, unexpired
// cookie issued with this server's key.
func (a *authState) verify(value string) (string, bool) {
p, sig, ok := strings.Cut(value, ".")
if !ok {
return "", false
}
got, err := base64.RawURLEncoding.DecodeString(sig)
if err != nil || !hmac.Equal(got, a.sign(p)) {
return "", false
}
raw, err := base64.RawURLEncoding.DecodeString(p)
if err != nil {
return "", false
}
var sp sessionPayload
if json.Unmarshal(raw, &sp) != nil || sp.User == "" || a.now().Unix() >= sp.Exp {
return "", false
}
return sp.User, true
}
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (a *authState) setCookie(w http.ResponseWriter, r *http.Request, user string) {
value, exp := a.issue(user)
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: value, Path: "/", Expires: exp,
MaxAge: int(a.ttl.Seconds()), HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
})
}
func clearCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: isHTTPS(r), SameSite: http.SameSiteStrictMode,
})
}
// ---- request context ----
type ctxKey struct{}
func userFromRequest(r *http.Request) string {
u, _ := r.Context().Value(ctxKey{}).(string)
return u
}
// ---- middleware ----
// authMiddleware gates every route except the login page, logout and static
// assets. With login disabled it is a pass-through (no CSRF check either).
func (s *Server) authMiddleware(next http.Handler) http.Handler {
a := s.auth
if !a.enabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !safeMethod(r.Method) && !sameOrigin(r) {
s.Log.Warn("rejected cross-origin request", "method", r.Method, "path", r.URL.Path)
http.Error(w, "forbidden: cross-origin request", http.StatusForbidden)
return
}
if isOpenPath(r) {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(sessionCookieName); err == nil {
if user, ok := a.verify(c.Value); ok {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, user)))
return
}
}
// htmx polls fragments in the background: answer with an
// HX-Redirect instead of a redirect whose login page would be
// swapped into the fragment.
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/login")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
target := "/login"
if r.Method == http.MethodGet {
if n := r.URL.RequestURI(); n != "/" {
target += "?next=" + url.QueryEscape(n)
}
}
http.Redirect(w, r, target, http.StatusSeeOther)
})
}
func isOpenPath(r *http.Request) bool {
switch r.URL.Path {
case "/login":
return r.Method == http.MethodGet || r.Method == http.MethodPost
case "/logout":
return r.Method == http.MethodPost
}
return strings.HasPrefix(r.URL.Path, "/static/") && (r.Method == http.MethodGet || r.Method == http.MethodHead)
}
func safeMethod(m string) bool {
return m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions
}
// sameOrigin implements the CSRF check: Origin (or, when absent, Referer)
// must name this very host. Browsers always send Origin on cross-site
// POSTs; a request with neither header is refused.
func sameOrigin(r *http.Request) bool {
h := r.Header.Get("Origin")
if h == "" {
h = r.Header.Get("Referer")
}
if h == "" || h == "null" {
return false
}
u, err := url.Parse(h)
if err != nil || u.Host == "" {
return false
}
return strings.EqualFold(u.Host, r.Host)
}
// safeNext returns next only if it is a same-origin relative path, so the
// post-login redirect can never leave the site.
func safeNext(next string) string {
if next == "" || next[0] != '/' || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/"
}
for _, c := range next {
if c < 0x20 || c == 0x7f || c == '\\' {
return "/"
}
}
u, err := url.Parse(next)
if err != nil || u.Scheme != "" || u.Host != "" {
return "/"
}
return next
}
// ---- brute-force throttle ----
type loginThrottle struct {
mu sync.Mutex
failures map[string][]time.Time
}
func newLoginThrottle() *loginThrottle {
return &loginThrottle{failures: map[string][]time.Time{}}
}
// prune drops expired failures (all clients) — caller holds mu. The map is
// bounded by the number of distinct IPs that failed in the last window.
func (t *loginThrottle) prune(now time.Time) {
for ip, fs := range t.failures {
i := 0
for i < len(fs) && now.Sub(fs[i]) >= loginFailureWindow {
i++
}
if i == len(fs) {
delete(t.failures, ip)
} else if i > 0 {
t.failures[ip] = fs[i:]
}
}
}
// blocked reports whether ip is locked out, and for how long.
func (t *loginThrottle) blocked(ip string, now time.Time) (bool, time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
t.prune(now)
fs := t.failures[ip]
if len(fs) < maxLoginFailures {
return false, 0
}
return true, fs[0].Add(loginFailureWindow).Sub(now)
}
func (t *loginThrottle) fail(ip string, now time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
t.prune(now)
t.failures[ip] = append(t.failures[ip], now)
}
func (t *loginThrottle) clear(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.failures, ip)
}
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// ---- handlers ----
type loginPageData struct {
Error string
Next string
}
func (s *Server) renderLogin(w http.ResponseWriter, status int, data loginPageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
if err := s.tmpl.ExecuteTemplate(w, "login_page", data); err != nil {
s.Log.Error("render login", "err", err)
}
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if !s.auth.enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if c, err := r.Cookie(sessionCookieName); err == nil {
if _, ok := s.auth.verify(c.Value); ok {
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
return
}
}
s.renderLogin(w, http.StatusOK, loginPageData{Next: safeNext(r.URL.Query().Get("next"))})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
a := s.auth
if !a.enabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
next := safeNext(r.PostForm.Get("next"))
ip := clientIP(r)
now := a.now()
if blocked, wait := a.throttle.blocked(ip, now); blocked {
secs := int(wait.Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(secs))
s.Log.Warn("login throttled", "remote", ip)
s.renderLogin(w, http.StatusTooManyRequests, loginPageData{Next: next, Error: "Слишком много попыток входа. Повторите позже."})
return
}
user := r.PostForm.Get("username")
if !a.checkCredentials(user, r.PostForm.Get("password")) {
a.throttle.fail(ip, now)
s.Log.Warn("login failed", "remote", ip)
s.renderLogin(w, http.StatusOK, loginPageData{Next: next, Error: "Неверный логин или пароль"})
return
}
a.throttle.clear(ip)
a.setCookie(w, r, user)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
clearCookie(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// withAuthInfo returns a copy of the page-data struct data with its embedded
// PageData's AuthEnabled/User filled in, so the sidebar can show the logout
// control. Data without an embedded PageData is returned unchanged.
func (s *Server) withAuthInfo(r *http.Request, data interface{}) interface{} {
if !s.auth.enabled || data == nil {
return data
}
v := reflect.ValueOf(data)
if v.Kind() != reflect.Struct {
return data
}
cp := reflect.New(v.Type()).Elem()
cp.Set(v)
pd := cp.FieldByName("PageData")
if !pd.IsValid() || pd.Type() != reflect.TypeOf(PageData{}) {
return data
}
pd.FieldByName("AuthEnabled").SetBool(true)
pd.FieldByName("User").SetString(userFromRequest(r))
return cp.Interface()
}
+422
View File
@@ -0,0 +1,422 @@
package dashboard
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
)
const (
testUser = "admin"
testPass = "correct horse battery staple"
testSecret = "test-session-secret"
)
func newAuthTestServer(t *testing.T, caURL string, mutate func(*Config)) (*Server, *httptest.Server) {
t.Helper()
cfg := Config{
ControlAPIBaseURL: caURL,
ControlAPITimeout: 5 * time.Second,
LastCompletedCount: 20,
OverviewPollIntervalS: 5,
ControlAPIToken: "ca-admin-token",
Username: testUser,
Password: testPass,
SessionSecret: testSecret,
SessionTTL: time.Hour,
}
if mutate != nil {
mutate(&cfg)
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, log)
if err != nil {
t.Fatalf("new dashboard server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return srv, ts
}
// noFollow is a client that returns redirects as-is.
func noFollow(ts *httptest.Server) *http.Client {
c := *ts.Client()
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &c
}
type reqOpts struct {
method string
path string
form url.Values
cookie string
headers map[string]string
}
func doReq(t *testing.T, ts *httptest.Server, o reqOpts) (*http.Response, string) {
t.Helper()
if o.method == "" {
o.method = http.MethodGet
}
var body io.Reader
if o.form != nil {
body = strings.NewReader(o.form.Encode())
}
req, err := http.NewRequest(o.method, ts.URL+o.path, body)
if err != nil {
t.Fatalf("new request: %v", err)
}
if o.form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if o.cookie != "" {
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: o.cookie})
}
for k, v := range o.headers {
req.Header.Set(k, v)
}
resp, err := noFollow(ts).Do(req)
if err != nil {
t.Fatalf("%s %s: %v", o.method, o.path, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, string(b)
}
func sessionCookie(resp *http.Response) *http.Cookie {
for _, c := range resp.Cookies() {
if c.Name == sessionCookieName {
return c
}
}
return nil
}
func loginForm(user, pass string) url.Values {
return url.Values{"username": {user}, "password": {pass}}
}
// login performs a successful login and returns the session cookie value.
func login(t *testing.T, ts *httptest.Server) string {
t.Helper()
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL}})
c := sessionCookie(resp)
if resp.StatusCode != http.StatusSeeOther || c == nil {
t.Fatalf("login: status=%d cookie=%v, want 303 + session cookie", resp.StatusCode, c)
}
return c.Value
}
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{path: "/ips"})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status=%d, want 303", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/login?next=%2Fips" {
t.Fatalf("Location=%q, want /login?next=%%2Fips", loc)
}
}
func TestUnauthenticatedHTMXGets401WithHXRedirect(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{path: "/overview/fragment", headers: map[string]string{"HX-Request": "true"}})
if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("HX-Redirect") != "/login" {
t.Fatalf("status=%d HX-Redirect=%q, want 401 + /login", resp.StatusCode, resp.Header.Get("HX-Redirect"))
}
}
func TestLoginSuccessSetsCookieAndGrantsAccess(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"/ips"}},
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/ips" {
t.Fatalf("status=%d Location=%q, want 303 /ips", resp.StatusCode, resp.Header.Get("Location"))
}
c := sessionCookie(resp)
if c == nil {
t.Fatal("no session cookie")
}
if !c.HttpOnly || c.SameSite != http.SameSiteStrictMode || c.Path != "/" || c.MaxAge <= 0 {
t.Fatalf("cookie flags: httponly=%v samesite=%v path=%q maxage=%d", c.HttpOnly, c.SameSite, c.Path, c.MaxAge)
}
if c.Secure {
t.Fatal("cookie must not be Secure over plain HTTP")
}
resp, body := doReq(t, ts, reqOpts{path: "/overview", cookie: c.Value})
if resp.StatusCode != http.StatusOK {
t.Fatalf("authenticated GET /overview: status=%d", resp.StatusCode)
}
if !strings.Contains(body, "Выйти") || !strings.Contains(body, testUser) {
t.Fatal("sidebar must show the user and the logout button when auth is enabled")
}
}
func TestCookieSecureBehindHTTPSProxy(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL, "X-Forwarded-Proto": "https"}})
if c := sessionCookie(resp); c == nil || !c.Secure {
t.Fatalf("cookie=%v, want Secure with X-Forwarded-Proto=https", c)
}
}
func TestLoginWrongPasswordShowsErrorWithoutCookie(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
for _, f := range []url.Values{loginForm(testUser, "nope"), loginForm("nobody", testPass)} {
resp, body := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: f,
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK || sessionCookie(resp) != nil {
t.Fatalf("status=%d cookie=%v, want 200 and no cookie", resp.StatusCode, sessionCookie(resp))
}
if !strings.Contains(body, "Неверный логин или пароль") {
t.Fatal("login page must show the error")
}
}
}
func TestTamperedAndExpiredCookiesRejected(t *testing.T) {
_, caURL := newFakeControlAPI(t)
srv, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
payload, sig, _ := strings.Cut(good, ".")
for name, v := range map[string]string{
"tampered payload": "AAAA" + payload[4:] + "." + sig,
"tampered signature": payload + "." + sig[:len(sig)-2] + "AA",
"no signature": payload,
"garbage": "x.y",
"signed with new key": otherKeyCookie(t),
} {
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: v})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("%s: status=%d, want 303 to /login", name, resp.StatusCode)
}
}
// Expiry: advance the server's clock beyond the TTL.
srv.auth.now = func() time.Time { return time.Now().Add(2 * time.Hour) }
resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good})
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("expired cookie: status=%d, want 303", resp.StatusCode)
}
}
func otherKeyCookie(t *testing.T) string {
t.Helper()
a := newAuthState(Config{Username: "u", Password: "p", SessionSecret: "another-secret"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
v, _ := a.issue(testUser)
return v
}
func TestLogoutClearsCookie(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/logout", cookie: good,
headers: map[string]string{"Origin": ts.URL}})
c := sessionCookie(resp)
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" || c == nil || c.MaxAge >= 0 || c.Value != "" {
t.Fatalf("logout: status=%d loc=%q cookie=%+v", resp.StatusCode, resp.Header.Get("Location"), c)
}
}
func TestCSRFOriginCheck(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
cases := map[string]map[string]string{
"foreign origin": {"Origin": "http://evil.example"},
"null origin": {"Origin": "null"},
"no origin/referer": {},
"foreign referer": {"Referer": "http://evil.example/page"},
}
for name, h := range cases {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("%s: status=%d, want 403", name, resp.StatusCode)
}
}
// Login itself is covered too.
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": "http://evil.example"}})
if resp.StatusCode != http.StatusForbidden || sessionCookie(resp) != nil {
t.Fatalf("cross-origin login: status=%d, want 403 and no cookie", resp.StatusCode)
}
// Same-origin Origin, and Referer fallback, pass.
for name, h := range map[string]map[string]string{
"origin": {"Origin": ts.URL},
"referer": {"Referer": ts.URL + "/ips"},
} {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/ips/clear", cookie: good, headers: h})
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusSeeOther {
t.Fatalf("same-origin %s: status=%d, want request to be served", name, resp.StatusCode)
}
}
}
func TestLoginThrottle(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
for i := 0; i < maxLoginFailures; i++ {
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, "bad"),
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("attempt %d: status=%d, want 200", i+1, resp.StatusCode)
}
}
// Now locked out, even with the right password.
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login", form: loginForm(testUser, testPass),
headers: map[string]string{"Origin": ts.URL}})
if resp.StatusCode != http.StatusTooManyRequests || resp.Header.Get("Retry-After") == "" || sessionCookie(resp) != nil {
t.Fatalf("status=%d Retry-After=%q, want 429 with Retry-After and no cookie", resp.StatusCode, resp.Header.Get("Retry-After"))
}
}
func TestLoginThrottleClearedBySuccessAndExpires(t *testing.T) {
th := newLoginThrottle()
now := time.Now()
for i := 0; i < maxLoginFailures-1; i++ {
th.fail("1.2.3.4", now)
}
if b, _ := th.blocked("1.2.3.4", now); b {
t.Fatal("blocked before reaching the limit")
}
th.fail("1.2.3.4", now)
if b, _ := th.blocked("1.2.3.4", now); !b {
t.Fatal("not blocked at the limit")
}
if b, _ := th.blocked("1.2.3.4", now.Add(loginFailureWindow+time.Second)); b {
t.Fatal("still blocked after the window")
}
if len(th.failures) != 0 {
t.Fatal("expired entries must be pruned")
}
th.fail("5.6.7.8", now)
th.clear("5.6.7.8")
if len(th.failures) != 0 {
t.Fatal("clear must drop the entry")
}
}
func TestStaticAndLoginPageOpen(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
if resp, _ := doReq(t, ts, reqOpts{path: "/static/dashboard.css"}); resp.StatusCode != http.StatusOK {
t.Fatalf("/static/dashboard.css: status=%d, want 200", resp.StatusCode)
}
resp, body := doReq(t, ts, reqOpts{path: "/login"})
if resp.StatusCode != http.StatusOK || !strings.Contains(body, `name="password"`) {
t.Fatalf("/login: status=%d", resp.StatusCode)
}
}
func TestLoginNextOpenRedirectRejected(t *testing.T) {
for next, want := range map[string]string{
"/ips": "/ips",
"/ips?q=1": "/ips?q=1",
"//evil.example": "/",
"/\\evil.example": "/",
"http://evil.example": "/",
"https://evil.example/x": "/",
"javascript:alert(1)": "/",
"": "/",
"/a\r\nSet-Cookie: x": "/",
} {
if got := safeNext(next); got != want {
t.Fatalf("safeNext(%q)=%q, want %q", next, got, want)
}
}
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
resp, _ := doReq(t, ts, reqOpts{method: http.MethodPost, path: "/login",
form: url.Values{"username": {testUser}, "password": {testPass}, "next": {"//evil.example"}},
headers: map[string]string{"Origin": ts.URL}})
if resp.Header.Get("Location") != "/" {
t.Fatalf("Location=%q, want /", resp.Header.Get("Location"))
}
}
func TestControlAPITokenForwarded(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, nil)
good := login(t, ts)
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
t.Fatalf("GET /overview: status=%d", resp.StatusCode)
}
fake.authMu.Lock()
defer fake.authMu.Unlock()
if len(fake.authHeaders) == 0 {
t.Fatal("control-api was never called")
}
for _, h := range fake.authHeaders {
if h != "Bearer ca-admin-token" {
t.Fatalf("control-api Authorization=%q, want Bearer ca-admin-token", h)
}
}
}
func TestAuthDisabledWithZeroConfig(t *testing.T) {
fake, caURL := newFakeControlAPI(t)
ts := newTestServer(t, caURL) // zero auth fields
resp, body := doReq(t, ts, reqOpts{path: "/overview"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 without login", resp.StatusCode)
}
if strings.Contains(body, "/logout") {
t.Fatal("logout control must be hidden when auth is disabled")
}
if resp, _ := doReq(t, ts, reqOpts{path: "/login"}); resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/" {
t.Fatalf("/login with auth disabled: status=%d loc=%q, want redirect to /", resp.StatusCode, resp.Header.Get("Location"))
}
fake.authMu.Lock()
defer fake.authMu.Unlock()
for _, h := range fake.authHeaders {
if h != "" {
t.Fatalf("no token configured but Authorization=%q was sent", h)
}
}
}
func TestAuthDisabledWhenOnlyUsernameSet(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.Password = "" })
if resp, _ := doReq(t, ts, reqOpts{path: "/overview"}); resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 (auth disabled without password)", resp.StatusCode)
}
}
func TestEmptySessionSecretUsesRandomKey(t *testing.T) {
_, caURL := newFakeControlAPI(t)
_, ts := newAuthTestServer(t, caURL, func(c *Config) { c.SessionSecret = "" })
good := login(t, ts)
if resp, _ := doReq(t, ts, reqOpts{path: "/overview", cookie: good}); resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200 with random-key session", resp.StatusCode)
}
}
+5
View File
@@ -39,6 +39,8 @@ func (e *apiErr) Error() string {
type client struct {
baseURL string
http *http.Client
// token, when non-empty, is sent to control-api as a Bearer credential.
token string
}
func newClient(baseURL string, timeout time.Duration) *client {
@@ -61,6 +63,9 @@ func (c *client) do(ctx context.Context, method, path string, body, out interfac
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.http.Do(req)
if err != nil {
+10 -1
View File
@@ -46,6 +46,10 @@ type fakeControlAPI struct {
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
autoCycle autoCycleDTO
autoCycleDown bool
// authHeaders records the Authorization header of every request served.
authMu sync.Mutex
authHeaders []string
}
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, string) {
@@ -532,7 +536,12 @@ func (f *fakeControlAPI) handler() http.Handler {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
})
return mux
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.authMu.Lock()
f.authHeaders = append(f.authHeaders, r.Header.Get("Authorization"))
f.authMu.Unlock()
mux.ServeHTTP(w, r)
})
}
func (f *fakeControlAPI) findIP(addr string) int {
+1 -1
View File
@@ -31,7 +31,7 @@ func (s *Server) handleCheckTypesPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadCheckTypesPage(r)
data.ActiveNav = "check-types"
data.Banner = bannerFor(err)
s.renderPage(w, "checktypes_page", data)
s.renderPage(w, r, "checktypes_page", data)
}
func (s *Server) renderCheckTypesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+2 -2
View File
@@ -25,7 +25,7 @@ func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}
data.ActiveNav = "ips"
data.Banner = bannerFor(err)
s.renderPage(w, "ips_page", data)
s.renderPage(w, r, "ips_page", data)
}
func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
@@ -34,7 +34,7 @@ func (s *Server) handleIPDetail(w http.ResponseWriter, r *http.Request) {
data := ipDetailData{Detail: detail}
data.ActiveNav = "ips"
data.Banner = bannerFor(err)
s.renderPage(w, "ip_detail_page", data)
s.renderPage(w, r, "ip_detail_page", data)
}
// renderIPsTable re-fetches the current queue and renders the ips_table
+1 -1
View File
@@ -57,7 +57,7 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
data, err := s.loadOverview(r)
data.ActiveNav = "overview"
data.Banner = bannerFor(err)
s.renderPage(w, "overview_page", data)
s.renderPage(w, r, "overview_page", data)
}
// handleOverviewFragment serves both the recurring poll and every
+2 -2
View File
@@ -34,7 +34,7 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
}
data.ActiveNav = "registry"
data.Banner = bannerFor(err)
s.renderPage(w, "registry_page", data)
s.renderPage(w, r, "registry_page", data)
}
// filterRegistryItems narrows items to those whose address contains q
@@ -68,5 +68,5 @@ func (s *Server) handleRegistryDetail(w http.ResponseWriter, r *http.Request) {
data := registryDetailData{History: history}
data.ActiveNav = "registry"
data.Banner = bannerFor(err)
s.renderPage(w, "registry_detail_page", data)
s.renderPage(w, r, "registry_detail_page", data)
}
+1 -1
View File
@@ -29,7 +29,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
data.ActiveNav = "settings"
data.Banner = bannerFor(err)
s.renderPage(w, "settings_page", data)
s.renderPage(w, r, "settings_page", data)
}
// renderSettingsForm re-fetches the current settings and renders the
+1 -1
View File
@@ -16,7 +16,7 @@ func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) {
data := sitesPageData{Items: items}
data.ActiveNav = "sites"
data.Banner = bannerFor(err)
s.renderPage(w, "sites_page", data)
s.renderPage(w, r, "sites_page", data)
}
func (s *Server) renderSitesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+1 -1
View File
@@ -83,7 +83,7 @@ func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadTargetsPage(r)
data.ActiveNav = "targets"
data.Banner = bannerFor(err)
s.renderPage(w, "targets_page", data)
s.renderPage(w, r, "targets_page", data)
}
// renderTargetsTable renders the #targets-table-wrap swap target plus,
+1 -1
View File
@@ -15,7 +15,7 @@ func (s *Server) handleValidatorsPage(w http.ResponseWriter, r *http.Request) {
data := validatorsPageData{Items: items}
data.ActiveNav = "validators"
data.Banner = bannerFor(err)
s.renderPage(w, "validators_page", data)
s.renderPage(w, r, "validators_page", data)
}
func (s *Server) renderValidatorsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
+6 -1
View File
@@ -155,6 +155,10 @@ type bannerData struct {
type PageData struct {
Banner bannerData
ActiveNav string
// AuthEnabled/User are filled in by renderPage (see withAuthInfo) so the
// sidebar can show the signed-in user and the logout button.
AuthEnabled bool
User string
}
func bannerFor(err error) bannerData {
@@ -172,8 +176,9 @@ func bannerFor(err error) bannerData {
// navigation. actionErr (if any — e.g. the primary control-api call for
// this page failed) is surfaced via the embedded PageData.Banner, which
// the caller must have already set via bannerFor.
func (s *Server) renderPage(w http.ResponseWriter, name string, data interface{}) {
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data = s.withAuthInfo(r, data)
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.Log.Error("render page", "template", name, "err", err)
}
+6
View File
@@ -3,6 +3,12 @@ package dashboard
import "net/http"
func (s *Server) routes(mux *http.ServeMux) {
// /login, /logout and /static/ are the only routes reachable without a
// session — see authMiddleware in auth.go.
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /overview", s.handleOverview)
+20 -2
View File
@@ -18,6 +18,19 @@ type Config struct {
ControlAPITimeout time.Duration
LastCompletedCount int
OverviewPollIntervalS int
// ControlAPIToken is the admin bearer token sent to control-api. Empty =
// no Authorization header.
ControlAPIToken string
// Username/Password are the single dashboard administrator's login. If
// either is empty, login is DISABLED (every page is open).
Username string
Password string
// SessionSecret is the HMAC key for session cookies; empty = random per
// process start (sessions are lost on restart).
SessionSecret string
// SessionTTL is the session lifetime (default 8h).
SessionTTL time.Duration
}
type Server struct {
@@ -25,6 +38,7 @@ type Server struct {
Cfg Config
tmpl *template.Template
Log *slog.Logger
auth *authState
}
func New(cfg Config, log *slog.Logger) (*Server, error) {
@@ -32,18 +46,22 @@ func New(cfg Config, log *slog.Logger) (*Server, error) {
if err != nil {
return nil, err
}
ca := newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout)
ca.token = cfg.ControlAPIToken
return &Server{
CA: newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout),
CA: ca,
Cfg: cfg,
tmpl: tmpl,
Log: log,
auth: newAuthState(cfg, log),
}, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return loggingMiddleware(s.Log, mux)
// Never log cookies, Authorization or form bodies here.
return loggingMiddleware(s.Log, s.authMiddleware(mux))
}
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
+16 -1
View File
@@ -337,7 +337,7 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; }
}
.form-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
input[type="text"], input[type="number"], input[type="search"], textarea, select {
input[type="text"], input[type="password"], input[type="number"], input[type="search"], textarea, select {
font-family: var(--font-ui);
font-size: 13.5px;
padding: 7px 10px;
@@ -544,3 +544,18 @@ tr.edit-row textarea { flex: 1 1 auto; }
Height is set by JS (see templates/targets.html), computed from the
field's own line-height, so it stays correct if font-size ever changes. */
textarea.autosize { overflow-y: hidden; resize: none; }
/* ---------- login page & sidebar user ---------- */
.login-shell { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px 16px; position: relative; }
.login-card { width: 100%; max-width: 380px; }
.login-card h1 { font-size: 18px; margin: 0 0 4px; }
.login-card .panel-body { display: flex; flex-direction: column; gap: 14px; }
/* .field is flex: 1 1 220px (for rows); in this column the basis would become a 220px height. */
.login-card .field { flex: 0 0 auto; }
.sidebar-user {
margin-top: auto; margin-bottom: 8px;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
font-family: var(--font-display); font-size: 11px; color: var(--text-muted);
}
.sidebar-user-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-user + .sidebar-foot { margin-top: 0; }
+7
View File
@@ -78,6 +78,13 @@
</a>
</nav>
{{if .AuthEnabled}}
<form class="sidebar-user" method="post" action="/logout">
<span class="sidebar-user-name" title="{{.User}}">{{.User}}</span>
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
</form>
{{end}}
<div class="sidebar-foot">
<span class="sidebar-foot-status"><span class="pulse-dot"></span>control-api</span>
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
+29
View File
@@ -0,0 +1,29 @@
{{define "login_page"}}
<!doctype html>
<html lang="ru">
<head>{{template "html_head" .}}</head>
<body>
<div class="bg-grid"></div>
<main class="login-shell">
<div class="login-card">
<form class="panel" method="post" action="/login" autocomplete="on">
<div class="panel-head"><h2>Cloud IP Validator — вход</h2></div>
<div class="panel-body">
{{if .Error}}<div class="alert alert-warning" role="alert">{{.Error}}</div>{{end}}
<input type="hidden" name="next" value="{{.Next}}">
<div class="field">
<label for="login-username">Логин</label>
<input type="text" id="login-username" name="username" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="login-password">Пароль</label>
<input type="password" id="login-password" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Войти</button>
</div>
</form>
</div>
</main>
</body>
</html>
{{end}}
+108
View File
@@ -0,0 +1,108 @@
package httpapi
import (
"crypto/sha256"
"crypto/subtle"
"log/slog"
"net/http"
"strings"
)
// accessLevel says which credential a route requires.
type accessLevel int
const (
// accessOpen: no credential required (registration, heartbeat, fetching
// the assignment, /healthz).
accessOpen accessLevel = iota + 1
// accessAgent: requires the static agent token (validator-agents and
// probers writing results/events).
accessAgent
// accessAdmin: requires the administrator token (/api/v1/admin/*).
accessAdmin
)
func (a accessLevel) String() string {
switch a {
case accessOpen:
return "open"
case accessAgent:
return "agent"
case accessAdmin:
return "admin"
}
return "invalid"
}
// Authenticator holds the static bearer tokens. An empty token disables the
// check for that level (backward compatibility: the API starts open with a
// warning, see cmd/control-api).
type Authenticator struct {
AdminToken string
AgentToken string
Log *slog.Logger
}
// WithAuth sets the admin and agent tokens. Call it before Handler().
func (s *Server) WithAuth(admin, agent string) *Server {
s.Auth = Authenticator{AdminToken: admin, AgentToken: agent}
return s
}
func (a Authenticator) tokenFor(level accessLevel) string {
switch level {
case accessAdmin:
return a.AdminToken
case accessAgent:
return a.AgentToken
}
return ""
}
// require wraps h so that it is only reached with the credential demanded by
// level. Open routes, and levels with an empty configured token, pass through.
func (a Authenticator) require(level accessLevel, h http.HandlerFunc) http.HandlerFunc {
if level == accessOpen {
return h
}
if level != accessAdmin && level != accessAgent {
// An unclassified route must never be served.
panic("httpapi: route registered without a valid access level")
}
return func(w http.ResponseWriter, r *http.Request) {
want := a.tokenFor(level)
if want == "" {
h(w, r)
return
}
got, ok := bearerToken(r)
if !ok || !tokensEqual(got, want) {
if a.Log != nil {
a.Log.Warn("unauthorized request", "level", level.String(),
"method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
}
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
h(w, r)
}
}
// bearerToken extracts the token from "Authorization: Bearer <token>".
func bearerToken(r *http.Request) (string, bool) {
h := r.Header.Get("Authorization")
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
tok := strings.TrimSpace(h[len(prefix):])
return tok, tok != ""
}
// tokensEqual compares in constant time; hashing first hides the length.
func tokensEqual(got, want string) bool {
g := sha256.Sum256([]byte(got))
w := sha256.Sum256([]byte(want))
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
}
+181
View File
@@ -0,0 +1,181 @@
package httpapi
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"cloudipvalidator/internal/config"
"cloudipvalidator/internal/db"
"cloudipvalidator/internal/openstack"
"cloudipvalidator/internal/orchestrator"
)
const (
testAdminToken = "admin-token-for-tests"
testAgentToken = "agent-token-for-tests"
)
func newAuthTestServer(t *testing.T, admin, agent string) (*Server, *httptest.Server) {
t.Helper()
ctx := context.Background()
d, err := db.Open(ctx, filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
cfg := &config.ControlAPI{
Orchestrator: config.OrchestratorConfig{
PollIntervalSeconds: 1, SelfCheckTimeoutSeconds: 10, MaxSelfCheckRetries: 3,
CheckingWindowSeconds: 120, MaxRetries: 3, LeaseTTLSeconds: 180, HeartbeatTimeoutSeconds: 30,
},
Inbound: config.InboundConfig{Ports: []int{22}, ICMP: true},
}
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
t.Fatalf("bootstrap: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv := New(d, orchestrator.New(d, openstack.NewMockClient(), cfg, log), log).WithAuth(admin, agent)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return srv, ts
}
// concretePath fills the {wildcards} of a ServeMux pattern with dummy values.
func concretePath(pattern string) (method, path string) {
method, path, _ = strings.Cut(pattern, " ")
var b strings.Builder
for {
i := strings.IndexByte(path, '{')
if i < 0 {
b.WriteString(path)
break
}
j := strings.IndexByte(path, '}')
b.WriteString(path[:i])
b.WriteString("1")
path = path[j+1:]
}
return method, b.String()
}
func callWithToken(t *testing.T, ts *httptest.Server, pattern, token string) *http.Response {
t.Helper()
method, path := concretePath(pattern)
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader("{}"))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("%s: %v", pattern, err)
}
resp.Body.Close()
return resp
}
func TestRouteTableIsClassified(t *testing.T) {
srv, _ := newAuthTestServer(t, "", "")
counts := map[string]int{}
for _, rt := range srv.Routes() {
counts[rt.Access]++
if rt.Access == "invalid" {
t.Fatalf("route %q has no valid access level", rt.Pattern)
}
if strings.Contains(rt.Pattern, "/api/v1/admin/") && rt.Access != "admin" {
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
}
}
if counts["admin"] != 33 || counts["agent"] != 5 || counts["open"] != 7 {
t.Fatalf("access counts = %v, want admin=33 agent=5 open=7", counts)
}
}
func TestAuthMatrixOverRouteTable(t *testing.T) {
srv, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
for _, rt := range srv.Routes() {
rt := rt
t.Run(rt.Pattern, func(t *testing.T) {
tokens := []struct {
name, token string
allowed bool
}{
{"none", "", rt.Access == "open"},
{"wrong", "definitely-wrong", rt.Access == "open"},
{"admin token", testAdminToken, rt.Access == "open" || rt.Access == "admin"},
{"agent token", testAgentToken, rt.Access == "open" || rt.Access == "agent"},
}
for _, tc := range tokens {
resp := callWithToken(t, ts, rt.Pattern, tc.token)
if tc.allowed && resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("%s: got 401, want request to pass auth", tc.name)
}
if !tc.allowed {
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s: status=%d, want 401", tc.name, resp.StatusCode)
}
if resp.Header.Get("WWW-Authenticate") != "Bearer" {
t.Fatalf("%s: missing WWW-Authenticate: Bearer", tc.name)
}
}
}
})
}
}
func TestEmptyTokensLeaveEverythingOpen(t *testing.T) {
srv, ts := newAuthTestServer(t, "", "")
for _, rt := range srv.Routes() {
if resp := callWithToken(t, ts, rt.Pattern, ""); resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("%s: got 401 with no tokens configured", rt.Pattern)
}
}
}
func TestOnlyConfiguredLevelIsEnforced(t *testing.T) {
_, ts := newAuthTestServer(t, testAdminToken, "")
if resp := callWithToken(t, ts, "GET /api/v1/admin/status", ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("admin without token: status=%d, want 401", resp.StatusCode)
}
if resp := callWithToken(t, ts, "POST /api/v1/agents/{id}/results", ""); resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("agent route must stay open while agent token is unset")
}
}
func TestHealthzAlwaysOpenAndBearerParsing(t *testing.T) {
_, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
if resp := callWithToken(t, ts, "GET /healthz", ""); resp.StatusCode != http.StatusOK {
t.Fatalf("healthz: status=%d, want 200", resp.StatusCode)
}
// Raw token without the Bearer scheme must not authenticate.
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
req.Header.Set("Authorization", testAdminToken)
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("scheme-less token: status=%d, want 401", resp.StatusCode)
}
// Lowercase scheme is accepted (RFC 7235: case-insensitive).
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
req.Header.Set("Authorization", "bearer "+testAdminToken)
resp, err = ts.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("lowercase bearer: status=%d, want 200", resp.StatusCode)
}
}
+75 -53
View File
@@ -2,61 +2,83 @@ package httpapi
import "net/http"
func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", s.handleHealthz)
// route is one entry of the Control API route table. The access level is
// mandatory: every route must state who may call it, so a new endpoint cannot
// be exposed by accident. The same table is used by the tests.
type route struct {
Pattern string
Handler http.HandlerFunc
Access accessLevel
}
mux.HandleFunc("POST /api/v1/agents/register", s.handleAgentRegister)
mux.HandleFunc("POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat)
mux.HandleFunc("GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment)
mux.HandleFunc("POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck)
mux.HandleFunc("POST /api/v1/agents/{id}/events", s.handleAgentEvent)
mux.HandleFunc("POST /api/v1/agents/{id}/results", s.handleAgentResults)
mux.HandleFunc("POST /api/v1/agents/{id}/complete", s.handleAgentComplete)
// RouteInfo is the exported, handler-free view of a route table entry.
type RouteInfo struct {
Pattern string
Access string // "open", "agent" or "admin"
}
mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister)
mux.HandleFunc("POST /api/v1/probers/{site_id}/heartbeat", s.handleProberHeartbeat)
mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments)
mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults)
// Routes returns the access classification of every registered route.
func (s *Server) Routes() []RouteInfo {
table := s.routeTable()
out := make([]RouteInfo, 0, len(table))
for _, rt := range table {
out = append(out, RouteInfo{Pattern: rt.Pattern, Access: rt.Access.String()})
}
return out
}
mux.HandleFunc("GET /api/v1/admin/status", s.handleAdminStatus)
mux.HandleFunc("GET /api/v1/admin/ips", s.handleAdminIPs)
mux.HandleFunc("POST /api/v1/admin/ips", s.handleAdminSubmitIPs)
mux.HandleFunc("POST /api/v1/admin/ips/scan", s.handleAdminScanFloatingIPs)
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail)
mux.HandleFunc("POST /api/v1/admin/ips/{ip}/cancel", s.handleAdminCancelIP)
mux.HandleFunc("DELETE /api/v1/admin/ips/{ip}", s.handleAdminDeleteIP)
mux.HandleFunc("POST /api/v1/admin/ips/delete", s.handleAdminDeleteIPs)
mux.HandleFunc("POST /api/v1/admin/ips/clear", s.handleAdminClearQueue)
mux.HandleFunc("GET /api/v1/admin/validators", s.handleAdminValidators)
mux.HandleFunc("GET /api/v1/admin/auto-cycle", s.handleAdminGetAutoCycle)
mux.HandleFunc("PUT /api/v1/admin/auto-cycle", s.handleAdminPutAutoCycle)
mux.HandleFunc("POST /api/v1/admin/auto-cycle/start", s.handleAdminStartAutoCycle)
mux.HandleFunc("POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle)
mux.HandleFunc("GET /api/v1/admin/registry", s.handleAdminRegistry)
mux.HandleFunc("GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory)
mux.HandleFunc("GET /api/v1/admin/config/validators", s.handleConfigListValidators)
mux.HandleFunc("POST /api/v1/admin/config/validators", s.handleConfigCreateValidator)
mux.HandleFunc("PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator)
mux.HandleFunc("DELETE /api/v1/admin/config/validators/{id}", s.handleConfigDeleteValidator)
mux.HandleFunc("GET /api/v1/admin/config/sites", s.handleConfigListSites)
mux.HandleFunc("PUT /api/v1/admin/config/sites/{index}", s.handleConfigPutSite)
mux.HandleFunc("DELETE /api/v1/admin/config/sites/{index}", s.handleConfigDeleteSite)
mux.HandleFunc("GET /api/v1/admin/config/targets", s.handleConfigListTargets)
mux.HandleFunc("PUT /api/v1/admin/config/targets/{group}", s.handleConfigPutTargetGroup)
mux.HandleFunc("DELETE /api/v1/admin/config/targets/{group}", s.handleConfigDeleteTargetGroup)
mux.HandleFunc("GET /api/v1/admin/config/check-types", s.handleConfigListCheckTypes)
mux.HandleFunc("PUT /api/v1/admin/config/check-types/{name}", s.handleConfigPutCheckType)
mux.HandleFunc("DELETE /api/v1/admin/config/check-types/{name}", s.handleConfigDeleteCheckType)
mux.HandleFunc("GET /api/v1/admin/config/orchestrator", s.handleConfigGetOrchestratorSettings)
mux.HandleFunc("PUT /api/v1/admin/config/orchestrator", s.handleConfigPutOrchestratorSettings)
func (s *Server) routes(mux *http.ServeMux) {
for _, rt := range s.routeTable() {
mux.HandleFunc(rt.Pattern, s.Auth.require(rt.Access, rt.Handler))
}
}
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", s.handleConfigGetInboundChecks)
mux.HandleFunc("PUT /api/v1/admin/config/inbound-checks", s.handleConfigPutInboundChecks)
func (s *Server) routeTable() []route {
return []route{
{"GET /healthz", s.handleHealthz, accessOpen},
{"POST /api/v1/agents/register", s.handleAgentRegister, accessOpen},
{"POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat, accessOpen},
{"GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment, accessOpen},
{"POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck, accessAgent},
{"POST /api/v1/agents/{id}/events", s.handleAgentEvent, accessAgent},
{"POST /api/v1/agents/{id}/results", s.handleAgentResults, accessAgent},
{"POST /api/v1/agents/{id}/complete", s.handleAgentComplete, accessAgent},
{"POST /api/v1/probers/register", s.handleProberRegister, accessOpen},
{"POST /api/v1/probers/{site_id}/heartbeat", s.handleProberHeartbeat, accessOpen},
{"GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments, accessOpen},
{"POST /api/v1/probers/{site_id}/results", s.handleProberResults, accessAgent},
{"GET /api/v1/admin/status", s.handleAdminStatus, accessAdmin},
{"GET /api/v1/admin/ips", s.handleAdminIPs, accessAdmin},
{"POST /api/v1/admin/ips", s.handleAdminSubmitIPs, accessAdmin},
{"POST /api/v1/admin/ips/scan", s.handleAdminScanFloatingIPs, accessAdmin},
{"GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail, accessAdmin},
{"POST /api/v1/admin/ips/{ip}/cancel", s.handleAdminCancelIP, accessAdmin},
{"DELETE /api/v1/admin/ips/{ip}", s.handleAdminDeleteIP, accessAdmin},
{"POST /api/v1/admin/ips/delete", s.handleAdminDeleteIPs, accessAdmin},
{"POST /api/v1/admin/ips/clear", s.handleAdminClearQueue, accessAdmin},
{"GET /api/v1/admin/validators", s.handleAdminValidators, accessAdmin},
{"GET /api/v1/admin/auto-cycle", s.handleAdminGetAutoCycle, accessAdmin},
{"PUT /api/v1/admin/auto-cycle", s.handleAdminPutAutoCycle, accessAdmin},
{"POST /api/v1/admin/auto-cycle/start", s.handleAdminStartAutoCycle, accessAdmin},
{"POST /api/v1/admin/auto-cycle/stop", s.handleAdminStopAutoCycle, accessAdmin},
{"GET /api/v1/admin/registry", s.handleAdminRegistry, accessAdmin},
{"GET /api/v1/admin/registry/{ip}", s.handleAdminRegistryHistory, accessAdmin},
{"GET /api/v1/admin/config/validators", s.handleConfigListValidators, accessAdmin},
{"POST /api/v1/admin/config/validators", s.handleConfigCreateValidator, accessAdmin},
{"PUT /api/v1/admin/config/validators/{id}", s.handleConfigUpdateValidator, accessAdmin},
{"DELETE /api/v1/admin/config/validators/{id}", s.handleConfigDeleteValidator, accessAdmin},
{"GET /api/v1/admin/config/sites", s.handleConfigListSites, accessAdmin},
{"PUT /api/v1/admin/config/sites/{index}", s.handleConfigPutSite, accessAdmin},
{"DELETE /api/v1/admin/config/sites/{index}", s.handleConfigDeleteSite, accessAdmin},
{"GET /api/v1/admin/config/targets", s.handleConfigListTargets, accessAdmin},
{"PUT /api/v1/admin/config/targets/{group}", s.handleConfigPutTargetGroup, accessAdmin},
{"DELETE /api/v1/admin/config/targets/{group}", s.handleConfigDeleteTargetGroup, accessAdmin},
{"GET /api/v1/admin/config/check-types", s.handleConfigListCheckTypes, accessAdmin},
{"PUT /api/v1/admin/config/check-types/{name}", s.handleConfigPutCheckType, accessAdmin},
{"DELETE /api/v1/admin/config/check-types/{name}", s.handleConfigDeleteCheckType, accessAdmin},
{"GET /api/v1/admin/config/orchestrator", s.handleConfigGetOrchestratorSettings, accessAdmin},
{"PUT /api/v1/admin/config/orchestrator", s.handleConfigPutOrchestratorSettings, accessAdmin},
{"GET /api/v1/admin/config/inbound-checks", s.handleConfigGetInboundChecks, accessAdmin},
{"PUT /api/v1/admin/config/inbound-checks", s.handleConfigPutInboundChecks, accessAdmin},
}
}
+2
View File
@@ -19,6 +19,7 @@ type Server struct {
DB *db.DB
Orch *orchestrator.Orchestrator
Log *slog.Logger
Auth Authenticator
}
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
@@ -26,6 +27,7 @@ func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
}
func (s *Server) Handler() http.Handler {
s.Auth.Log = s.Log
mux := http.NewServeMux()
s.routes(mux)
return loggingMiddleware(s.Log, mux)
+7
View File
@@ -44,6 +44,13 @@ func New(cfg *config.Prober, log *slog.Logger) *Prober {
}
}
// WithToken sets the bearer token sent to the Control API (and only to it:
// probes use their own dialers).
func (p *Prober) WithToken(token string) *Prober {
p.client.Token = token
return p
}
func (p *Prober) Run(ctx context.Context) error {
if err := p.registerWithRetry(ctx); err != nil {
return err
+28
View File
@@ -208,3 +208,31 @@ func TestExtraChecksForPort(t *testing.T) {
}
}
}
// TestWithTokenSendsBearerToControlAPI checks the prober's control-api
// client carries the agent token on every request.
func TestWithTokenSendsBearerToControlAPI(t *testing.T) {
var mu sync.Mutex
var auths []string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
auths = append(auths, r.Header.Get("Authorization"))
mu.Unlock()
w.Write([]byte(`[]`))
}))
defer ts.Close()
p := New(&config.Prober{SiteID: "site-1", ControlAPIURL: ts.URL}, testLogger()).WithToken("agent-secret")
p.pollOnce(context.Background())
mu.Lock()
defer mu.Unlock()
if len(auths) == 0 {
t.Fatal("no requests reached control-api")
}
for _, a := range auths {
if a != "Bearer agent-secret" {
t.Fatalf("Authorization = %q, want bearer token", a)
}
}
}
+11
View File
@@ -28,6 +28,10 @@ services:
OS_USERNAME: "${OS_USERNAME:-}"
OS_USER_DOMAIN_NAME: "${OS_USER_DOMAIN_NAME:-}"
OS_PASSWORD: "${OS_PASSWORD:-}"
# Static bearer tokens (set in .env; empty = that API level is open).
# Generate with: openssl rand -hex 32
CONTROL_API_ADMIN_TOKEN: "${CONTROL_API_ADMIN_TOKEN:-}"
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8081/healthz"]
interval: 5s
@@ -44,6 +48,12 @@ services:
- "8091:8090"
environment:
ADMIN_DASHBOARD_CONTROL_API_URL: http://control-api:8081
# Admin token of control-api + the dashboard login (empty username/
# password = login disabled).
ADMIN_DASHBOARD_CONTROL_API_TOKEN: "${ADMIN_DASHBOARD_CONTROL_API_TOKEN:-}"
ADMIN_DASHBOARD_USERNAME: "${ADMIN_DASHBOARD_USERNAME:-}"
ADMIN_DASHBOARD_PASSWORD: "${ADMIN_DASHBOARD_PASSWORD:-}"
ADMIN_DASHBOARD_SESSION_SECRET: "${ADMIN_DASHBOARD_SESSION_SECRET:-}"
depends_on:
control-api:
condition: service_healthy
@@ -57,6 +67,7 @@ services:
environment:
PROBER_SITE_ID: "${PROBER_SITE_ID}"
PROBER_CONTROL_API_URL: http://control-api:8081
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
depends_on:
control-api:
condition: service_healthy
@@ -4,6 +4,23 @@ server:
control_api:
base_url: "http://control-api.internal:8080"
timeout_seconds: 10
# Имя переменной окружения с admin-токеном control-api
# (CONTROL_API_ADMIN_TOKEN на стороне control-api). Пусто в окружении —
# запросы идут без заголовка Authorization.
token_env: "ADMIN_DASHBOARD_CONTROL_API_TOKEN"
# Вход в дашборд: один администратор, сессия в cookie. Здесь только ИМЕНА
# переменных окружения; значения задаются окружением процесса (см.
# deploy/systemd/admin-dashboard.service). Если логин или пароль не заданы —
# вход не требуется (в логе предупреждение при старте).
auth:
username_env: "ADMIN_DASHBOARD_USERNAME"
password_env: "ADMIN_DASHBOARD_PASSWORD"
# Ключ подписи cookie сессии (openssl rand -hex 32). Не задан — ключ
# случайный на каждый старт, и рестарт сбрасывает все сессии.
session_secret_env: "ADMIN_DASHBOARD_SESSION_SECRET"
# Срок жизни сессии, минут (по умолчанию 480 = 8 часов).
session_ttl_minutes: 480
# Настройки сводки на странице "Обзор" — см. docs/DASHBOARD.md. Оба поля
# влияют только на то, как дашборд группирует уже существующие данные
@@ -37,6 +37,22 @@ openstack:
user_domain_name_env: "OS_USER_DOMAIN_NAME"
password_env: "OS_PASSWORD"
# Аутентификация API: здесь только ИМЕНА переменных окружения, значения
# (статические bearer-токены) задаются окружением процесса — см.
# deploy/systemd/control-api.service (EnvironmentFile=). Генерация:
# openssl rand -hex 32
# Пустой/незаданный токен оставляет соответствующий уровень ОТКРЫТЫМ
# (в логе при старте предупреждение) — для обратной совместимости.
auth:
# Защищает все /api/v1/admin/* (его использует дашборд и оператор: curl -H
# "Authorization: Bearer $TOKEN").
admin_token_env: "CONTROL_API_ADMIN_TOKEN"
# Защищает запись результатов/событий: POST /agents/{id}/self-check|events|
# results|complete и POST /probers/{site_id}/results. Один общий токен для
# validator-agent и prober. register/heartbeat/получение задания остаются
# открытыми.
agent_token_env: "CONTROL_API_AGENT_TOKEN"
orchestrator:
poll_interval_seconds: 5
self_check_timeout_seconds: 60
@@ -3,6 +3,10 @@
site_id: "site-1"
control_api_url: "http://control-api.internal:8080"
# Имя переменной окружения с токеном агентов control-api (тот же, что у
# validator-agent; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для
# записи результатов; register/heartbeat/получение заданий работают без него.
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
poll_interval_seconds: 5
checks:
@@ -3,6 +3,12 @@
validator_id: "validator_01"
control_api_url: "http://control-api.internal:8080"
# Имя переменной окружения с токеном агентов control-api (тот же, что у
# prober; CONTROL_API_AGENT_TOKEN на стороне control-api). Нужен для записи
# результатов/событий; register/heartbeat/получение задания работают без него.
# Токен отправляется только в control-api — не на ip_echo_urls и не на цели
# проверок.
control_api_token_env: "CONTROL_API_AGENT_TOKEN"
poll_interval_seconds: 5
self_check:
+42 -9
View File
@@ -21,6 +21,14 @@ BIN_DIR="$WORK_DIR/bin"
LOG_DIR="$WORK_DIR/logs"
mkdir -p "$BIN_DIR" "$LOG_DIR"
# Static bearer tokens (fixed, test-only values). control-api enforces both;
# validator-agent and prober pick the agent token up from the same variable
# (the default name of their control_api_token_env); admin curls below send the
# admin token explicitly.
export CONTROL_API_ADMIN_TOKEN="e2e-admin-token-0123456789abcdef"
export CONTROL_API_AGENT_TOKEN="e2e-agent-token-fedcba9876543210"
ADMIN_AUTH=(-H "Authorization: Bearer $CONTROL_API_ADMIN_TOKEN")
PIDS=()
cleanup() {
echo "--- cleaning up (workdir: $WORK_DIR) ---"
@@ -148,6 +156,31 @@ for i in $(seq 1 30); do
fi
done
echo "--- authentication checks ---"
BASE="http://127.0.0.1:28080"
http_code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
expect_code() { # expect_code <want> <description> <curl args...>
local want="$1" desc="$2" got
shift 2
got="$(http_code "$@")"
if [ "$got" != "$want" ]; then
echo "FAIL: $desc: expected HTTP $want, got $got"
exit 1
fi
echo "ok: $desc -> $got"
}
expect_code 401 "admin endpoint without token" "$BASE/api/v1/admin/status"
expect_code 200 "admin endpoint with admin token" "${ADMIN_AUTH[@]}" "$BASE/api/v1/admin/status"
expect_code 401 "agent results without token" -X POST -H 'Content-Type: application/json' -d '{}' "$BASE/api/v1/agents/validator_01/results"
expect_code 401 "agent results with the ADMIN token" -X POST "${ADMIN_AUTH[@]}" -H 'Content-Type: application/json' -d '{}' "$BASE/api/v1/agents/validator_01/results"
# Open route: whatever the status (200/204/404 depending on state), it must not be 401.
assignment_code="$(http_code "$BASE/api/v1/agents/validator_01/assignment")"
if [ "$assignment_code" = "401" ]; then
echo "FAIL: GET /agents/validator_01/assignment must be open (no token), got 401"
exit 1
fi
echo "ok: assignment without token is not rejected -> $assignment_code"
start_validator_agent() {
"$BIN_DIR/validator-agent" -config "$WORK_DIR/validator-agent.yaml" -stub-ports "22022,28081,28443,28888" \
>>"$LOG_DIR/validator-agent.log" 2>&1 & VALIDATOR_PID=$!
@@ -155,8 +188,8 @@ start_validator_agent() {
echo "validator-agent started (pid $VALIDATOR_PID)"
}
status() { curl -fs "http://127.0.0.1:28080/api/v1/admin/status"; }
ips() { curl -fs "http://127.0.0.1:28080/api/v1/admin/ips"; }
status() { curl -fs "${ADMIN_AUTH[@]}" "http://127.0.0.1:28080/api/v1/admin/status"; }
ips() { curl -fs "${ADMIN_AUTH[@]}" "http://127.0.0.1:28080/api/v1/admin/ips"; }
ip_state() { ips | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['State'] if d else 'none')" 2>/dev/null || echo "?"; }
echo "--- starting validator-agent ---"
@@ -211,7 +244,7 @@ echo "--- final ip results ---"
ips | python3 -m json.tool
echo "--- demonstrating admin API: force a re-check of the already-done address ---"
curl -fs -X POST "http://127.0.0.1:28080/api/v1/admin/ips" \
curl -fs "${ADMIN_AUTH[@]}" -X POST "http://127.0.0.1:28080/api/v1/admin/ips" \
-H 'Content-Type: application/json' -d '{"addresses":["127.0.0.1"]}' | python3 -m json.tool
echo "--- waiting for the forced re-check to drain (up to 30s) ---"
@@ -229,7 +262,7 @@ ips | python3 -m json.tool
echo "--- automatic cycle: enable it and wait for the first cycle (clear queue -> scan FIPs -> checks -> registry) ---"
AC_URL="http://127.0.0.1:28080/api/v1/admin/auto-cycle"
registry_cycles() {
curl -fs "http://127.0.0.1:28080/api/v1/admin/registry" | python3 -c "
curl -fs "${ADMIN_AUTH[@]}" "http://127.0.0.1:28080/api/v1/admin/registry" | python3 -c "
import json,sys
for r in json.load(sys.stdin):
if r['ip_address'] == '127.0.0.1':
@@ -238,14 +271,14 @@ for r in json.load(sys.stdin):
else:
print(0)"
}
ac_field() { curl -fs "$AC_URL" | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
ac_field() { curl -fs "${ADMIN_AUTH[@]}" "$AC_URL" | python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
CYCLES_BEFORE="$(registry_cycles)"
# 60s is the smallest interval control-api accepts; the second cycle is
# covered by unit tests, so the script stops the auto-cycle after the first.
curl -fs -X PUT "$AC_URL" -H 'Content-Type: application/json' \
curl -fs "${ADMIN_AUTH[@]}" -X PUT "$AC_URL" -H 'Content-Type: application/json' \
-d '{"interval_seconds":60,"max_run_seconds":120}' >/dev/null
curl -fs -X POST "$AC_URL/start" | python3 -m json.tool
curl -fs "${ADMIN_AUTH[@]}" -X POST "$AC_URL/start" | python3 -m json.tool
echo "--- waiting for the first auto cycle to complete (up to 90s) ---"
AC_OUTCOME=""
@@ -259,7 +292,7 @@ for i in $(seq 1 180); do
done
echo "--- auto-cycle status ---"
curl -fs "$AC_URL" | python3 -m json.tool
curl -fs "${ADMIN_AUTH[@]}" "$AC_URL" | python3 -m json.tool
CYCLES_AFTER="$(registry_cycles)"
echo "registry total_cycles for 127.0.0.1: $CYCLES_BEFORE -> $CYCLES_AFTER"
@@ -277,7 +310,7 @@ if [ "$CYCLES_AFTER" -le "$CYCLES_BEFORE" ]; then
fi
echo "--- automatic cycle: disable it; no further cycles must start ---"
curl -fs -X POST "$AC_URL/stop" | python3 -m json.tool
curl -fs "${ADMIN_AUTH[@]}" -X POST "$AC_URL/stop" | python3 -m json.tool
if [ "$(ac_field enabled)" != "False" ] || [ "$(ac_field phase)" != "idle" ]; then
echo "FAIL: expected enabled=false and phase=idle after stop"
exit 1