diff --git a/README.md b/README.md index d9da091..4f14a6e 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS index e7277e9..1035e4b 100644 --- a/bin/SHA256SUMS +++ b/bin/SHA256SUMS @@ -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 diff --git a/bin/admin-dashboard b/bin/admin-dashboard index 22703b6..924bf0d 100755 Binary files a/bin/admin-dashboard and b/bin/admin-dashboard differ diff --git a/bin/control-api b/bin/control-api index f45b0db..1cfb7e7 100755 Binary files a/bin/control-api and b/bin/control-api differ diff --git a/bin/prober b/bin/prober index f7e37fe..3387f97 100755 Binary files a/bin/prober and b/bin/prober differ diff --git a/bin/validator-agent b/bin/validator-agent index 3dc452e..b8da4ad 100755 Binary files a/bin/validator-agent and b/bin/validator-agent differ diff --git a/cmd/admin-dashboard/main.go b/cmd/admin-dashboard/main.go index 012f14d..2995a02 100644 --- a/cmd/admin-dashboard/main.go +++ b/cmd/admin-dashboard/main.go @@ -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) diff --git a/cmd/control-api/main.go b/cmd/control-api/main.go index 7352285..eb6ef11 100644 --- a/cmd/control-api/main.go +++ b/cmd/control-api/main.go @@ -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) diff --git a/cmd/prober/main.go b/cmd/prober/main.go index 228aa23..0b1c817 100644 --- a/cmd/prober/main.go +++ b/cmd/prober/main.go @@ -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) diff --git a/cmd/validator-agent/main.go b/cmd/validator-agent/main.go index e63eb51..2ee15f3 100644 --- a/cmd/validator-agent/main.go +++ b/cmd/validator-agent/main.go @@ -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) diff --git a/configs/admin-dashboard.example.yaml b/configs/admin-dashboard.example.yaml index e2699f6..81345b0 100644 --- a/configs/admin-dashboard.example.yaml +++ b/configs/admin-dashboard.example.yaml @@ -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. Оба поля # влияют только на то, как дашборд группирует уже существующие данные diff --git a/configs/control-api.example.yaml b/configs/control-api.example.yaml index f9f2a1f..7edafb7 100644 --- a/configs/control-api.example.yaml +++ b/configs/control-api.example.yaml @@ -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 diff --git a/configs/prober.example.yaml b/configs/prober.example.yaml index 441b1d0..87effc8 100644 --- a/configs/prober.example.yaml +++ b/configs/prober.example.yaml @@ -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: diff --git a/configs/validator-agent.example.yaml b/configs/validator-agent.example.yaml index 5f3bd66..174fd82 100644 --- a/configs/validator-agent.example.yaml +++ b/configs/validator-agent.example.yaml @@ -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: diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index 2a210a5..fd731b8 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -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. diff --git a/deploy/docker/.env.prod.example b/deploy/docker/.env.prod.example index 3d88c22..753762d 100644 --- a/deploy/docker/.env.prod.example +++ b/deploy/docker/.env.prod.example @@ -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) diff --git a/deploy/docker/RUN.txt b/deploy/docker/RUN.txt index 87adaa0..a75752b 100644 --- a/deploy/docker/RUN.txt +++ b/deploy/docker/RUN.txt @@ -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= \ 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= \ + -e ADMIN_DASHBOARD_USERNAME= \ + -e ADMIN_DASHBOARD_PASSWORD= \ + -e ADMIN_DASHBOARD_SESSION_SECRET= \ 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= \ -e OS_REGION_NAME= \ -e OS_TOKEN= \ + -e CONTROL_API_ADMIN_TOKEN= \ + -e CONTROL_API_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= \ 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 diff --git a/deploy/docker/admin-dashboard/docker-entrypoint.sh b/deploy/docker/admin-dashboard/docker-entrypoint.sh index 95ac9b8..49a6e8c 100755 --- a/deploy/docker/admin-dashboard/docker-entrypoint.sh +++ b/deploy/docker/admin-dashboard/docker-entrypoint.sh @@ -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 diff --git a/deploy/docker/control-api/control-api.docker.example.yaml b/deploy/docker/control-api/control-api.docker.example.yaml index 663ee81..3e3d916 100644 --- a/deploy/docker/control-api/control-api.docker.example.yaml +++ b/deploy/docker/control-api/control-api.docker.example.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 diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 82f21d9..ac8219d 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -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:-}" diff --git a/deploy/docker/prober/docker-entrypoint.sh b/deploy/docker/prober/docker-entrypoint.sh index f1e8b18..d429b0a 100644 --- a/deploy/docker/prober/docker-entrypoint.sh +++ b/deploy/docker/prober/docker-entrypoint.sh @@ -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 diff --git a/deploy/docker/validator-agent/docker-entrypoint.sh b/deploy/docker/validator-agent/docker-entrypoint.sh index 6839fdf..aef94fc 100755 --- a/deploy/docker/validator-agent/docker-entrypoint.sh +++ b/deploy/docker/validator-agent/docker-entrypoint.sh @@ -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 diff --git a/deploy/systemd/admin-dashboard.service b/deploy/systemd/admin-dashboard.service index ff2a9a3..453d9ca 100644 --- a/deploy/systemd/admin-dashboard.service +++ b/deploy/systemd/admin-dashboard.service @@ -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 diff --git a/deploy/systemd/control-api.service b/deploy/systemd/control-api.service index b5df93c..c8c9020 100644 --- a/deploy/systemd/control-api.service +++ b/deploy/systemd/control-api.service @@ -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 diff --git a/deploy/systemd/prober.service b/deploy/systemd/prober.service index dab51a4..fbac289 100644 --- a/deploy/systemd/prober.service +++ b/deploy/systemd/prober.service @@ -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 diff --git a/deploy/systemd/validator-agent.service b/deploy/systemd/validator-agent.service index 4629a50..b50826f 100644 --- a/deploy/systemd/validator-agent.service +++ b/deploy/systemd/validator-agent.service @@ -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 diff --git a/docs/API.md b/docs/API.md index ca09a30..a49ed79 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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://:8080/api/v1/admin/status +``` + +> Во всех примерах `curl` ниже заголовок `Authorization` для краткости опущен; если токен администратора задан, добавляйте его к методам `/api/v1/admin/*`. + ## Общие соглашения - Тело запроса и ответа — JSON (`Content-Type: application/json`). diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 07eeee9..0c4cbd2 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -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). Подробности — [«Вход и сессия»](#вход-и-сессия). ## Отображение ошибок diff --git a/docs/SETUP.md b/docs/SETUP.md index 210c2de..74cb4b7 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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 diff --git a/docs/USAGE.md b/docs/USAGE.md index c330a09..4b49a5c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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`); +> для краткости он опущен. Дашборд запрашивает логин и пароль. + ## Содержание - [Как устроена работа с системой](#как-устроена-работа-с-системой) diff --git a/docs/changes/2026-10-01_11-12_authentication-plan.md b/docs/changes/2026-10-01_11-12_authentication-plan.md new file mode 100644 index 0000000..c46e949 --- /dev/null +++ b/docs/changes/2026-10-01_11-12_authentication-plan.md @@ -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 ` ко **всем** запросам к 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`: сервисы не теряют регистрацию в момент включения токенов. diff --git a/docs/changes/2026-10-01_11-31_authentication-review.md b/docs/changes/2026-10-01_11-31_authentication-review.md new file mode 100644 index 0000000..6172e60 --- /dev/null +++ b/docs/changes/2026-10-01_11-31_authentication-review.md @@ -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). diff --git a/internal/agentcore/agentcore.go b/internal/agentcore/agentcore.go index a90e208..64ea8d0 100644 --- a/internal/agentcore/agentcore.go +++ b/internal/agentcore/agentcore.go @@ -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 { diff --git a/internal/agentcore/agentcore_test.go b/internal/agentcore/agentcore_test.go index fcecb80..e985960 100644 --- a/internal/agentcore/agentcore_test.go +++ b/internal/agentcore/agentcore_test.go @@ -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) + } +} diff --git a/internal/apiclient/apiclient.go b/internal/apiclient/apiclient.go index b088e8d..8acc9b5 100644 --- a/internal/apiclient/apiclient.go +++ b/internal/apiclient/apiclient.go @@ -17,6 +17,9 @@ import ( type Client struct { BaseURL string HTTPClient *http.Client + // Token, when non-empty, is sent as "Authorization: Bearer " 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 { diff --git a/internal/apiclient/apiclient_test.go b/internal/apiclient/apiclient_test.go new file mode 100644 index 0000000..c2b4fe4 --- /dev/null +++ b/internal/apiclient/apiclient_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 511a33c..30010cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/dashboard/auth.go b/internal/dashboard/auth.go new file mode 100644 index 0000000..a504ef4 --- /dev/null +++ b/internal/dashboard/auth.go @@ -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() +} diff --git a/internal/dashboard/auth_test.go b/internal/dashboard/auth_test.go new file mode 100644 index 0000000..a6a2f99 --- /dev/null +++ b/internal/dashboard/auth_test.go @@ -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) + } +} diff --git a/internal/dashboard/client.go b/internal/dashboard/client.go index 02d8fd4..45684e6 100644 --- a/internal/dashboard/client.go +++ b/internal/dashboard/client.go @@ -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 { diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index 942bd07..ed6c5fb 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -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 { diff --git a/internal/dashboard/handlers_checktypes.go b/internal/dashboard/handlers_checktypes.go index 4200d33..4391eb9 100644 --- a/internal/dashboard/handlers_checktypes.go +++ b/internal/dashboard/handlers_checktypes.go @@ -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) { diff --git a/internal/dashboard/handlers_ips.go b/internal/dashboard/handlers_ips.go index ed91443..9644689 100644 --- a/internal/dashboard/handlers_ips.go +++ b/internal/dashboard/handlers_ips.go @@ -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 diff --git a/internal/dashboard/handlers_overview.go b/internal/dashboard/handlers_overview.go index 3f8686e..6eec7af 100644 --- a/internal/dashboard/handlers_overview.go +++ b/internal/dashboard/handlers_overview.go @@ -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 diff --git a/internal/dashboard/handlers_registry.go b/internal/dashboard/handlers_registry.go index af3d92d..45d1811 100644 --- a/internal/dashboard/handlers_registry.go +++ b/internal/dashboard/handlers_registry.go @@ -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) } diff --git a/internal/dashboard/handlers_settings.go b/internal/dashboard/handlers_settings.go index b97169e..d731062 100644 --- a/internal/dashboard/handlers_settings.go +++ b/internal/dashboard/handlers_settings.go @@ -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 diff --git a/internal/dashboard/handlers_sites.go b/internal/dashboard/handlers_sites.go index 14e4974..bdf08e9 100644 --- a/internal/dashboard/handlers_sites.go +++ b/internal/dashboard/handlers_sites.go @@ -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) { diff --git a/internal/dashboard/handlers_targets.go b/internal/dashboard/handlers_targets.go index 0a7ca6e..e213c52 100644 --- a/internal/dashboard/handlers_targets.go +++ b/internal/dashboard/handlers_targets.go @@ -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, diff --git a/internal/dashboard/handlers_validators.go b/internal/dashboard/handlers_validators.go index 2144901..37441b5 100644 --- a/internal/dashboard/handlers_validators.go +++ b/internal/dashboard/handlers_validators.go @@ -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) { diff --git a/internal/dashboard/render.go b/internal/dashboard/render.go index 120cd4b..3e6a20c 100644 --- a/internal/dashboard/render.go +++ b/internal/dashboard/render.go @@ -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) } diff --git a/internal/dashboard/routes.go b/internal/dashboard/routes.go index a6b17a3..d5cff68 100644 --- a/internal/dashboard/routes.go +++ b/internal/dashboard/routes.go @@ -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) diff --git a/internal/dashboard/server.go b/internal/dashboard/server.go index cfc5088..2681063 100644 --- a/internal/dashboard/server.go +++ b/internal/dashboard/server.go @@ -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 { diff --git a/internal/dashboard/static/dashboard.css b/internal/dashboard/static/dashboard.css index dd3615b..c9a889b 100644 --- a/internal/dashboard/static/dashboard.css +++ b/internal/dashboard/static/dashboard.css @@ -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; } diff --git a/internal/dashboard/templates/layout.html b/internal/dashboard/templates/layout.html index 3c455e8..db63dba 100644 --- a/internal/dashboard/templates/layout.html +++ b/internal/dashboard/templates/layout.html @@ -78,6 +78,13 @@ +{{if .AuthEnabled}} + +{{end}} + + + + + + +{{end}} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..6402ffc --- /dev/null +++ b/internal/httpapi/auth.go @@ -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 ". +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 +} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go new file mode 100644 index 0000000..fc365ad --- /dev/null +++ b/internal/httpapi/auth_test.go @@ -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) + } +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 0d7d744..6fe5a92 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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}, + } } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 18c684c..5d7ef02 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) diff --git a/internal/probercore/probercore.go b/internal/probercore/probercore.go index 1cff71b..6be8d40 100644 --- a/internal/probercore/probercore.go +++ b/internal/probercore/probercore.go @@ -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 diff --git a/internal/probercore/probercore_test.go b/internal/probercore/probercore_test.go index eb07076..6825db8 100644 --- a/internal/probercore/probercore_test.go +++ b/internal/probercore/probercore_test.go @@ -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) + } + } +} diff --git a/rxprod-compose/docker-compose.yml b/rxprod-compose/docker-compose.yml index 0a0fe73..be5470f 100644 --- a/rxprod-compose/docker-compose.yml +++ b/rxprod-compose/docker-compose.yml @@ -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 diff --git a/rxprod-compose/sources/admin-dashboard.example.yaml b/rxprod-compose/sources/admin-dashboard.example.yaml index e2699f6..81345b0 100644 --- a/rxprod-compose/sources/admin-dashboard.example.yaml +++ b/rxprod-compose/sources/admin-dashboard.example.yaml @@ -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. Оба поля # влияют только на то, как дашборд группирует уже существующие данные diff --git a/rxprod-compose/sources/control-api.example.yaml b/rxprod-compose/sources/control-api.example.yaml index f9f2a1f..7edafb7 100644 --- a/rxprod-compose/sources/control-api.example.yaml +++ b/rxprod-compose/sources/control-api.example.yaml @@ -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 diff --git a/rxprod-compose/sources/prober.example.yaml b/rxprod-compose/sources/prober.example.yaml index 441b1d0..87effc8 100644 --- a/rxprod-compose/sources/prober.example.yaml +++ b/rxprod-compose/sources/prober.example.yaml @@ -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: diff --git a/rxprod-compose/sources/validator-agent.example.yaml b/rxprod-compose/sources/validator-agent.example.yaml index 5f3bd66..174fd82 100644 --- a/rxprod-compose/sources/validator-agent.example.yaml +++ b/rxprod-compose/sources/validator-agent.example.yaml @@ -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: diff --git a/scripts/run-local-e2e.sh b/scripts/run-local-e2e.sh index fcfbf47..c32d731 100755 --- a/scripts/run-local-e2e.sh +++ b/scripts/run-local-e2e.sh @@ -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 + 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