Add authentication: admin/agent bearer tokens for the API, login for the dashboard
control-api: every route now carries a mandatory access level (admin / agent / open) in a route table. All /api/v1/admin/* require the admin token; the write calls of validator-agent and prober (self-check, events, results, complete) require a separate static agent token; register, heartbeat and fetching the assignment stay open. Tokens come from env vars, are compared in constant time and never logged. An empty token leaves that level open with a startup warning (backward compatible). validator-agent / prober: apiclient sends the agent token only to control-api. admin-dashboard: login/password (from env) with a stateless HMAC session cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for htmx polls, logout in the sidebar; the dashboard calls control-api with the admin token. Login page layout fixed after review. Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples, e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD, README), plan and review under docs/changes/, bin/ rebuilt with new SHA256SUMS. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
972ad47d0c
commit
debf2afed2
67 files changed
+2050
-105
No files matched your search
@@ -38,6 +38,7 @@ docker compose up -d --build # весь стенд на одной
|
|||||||
| `orchestrator.heartbeat_timeout_seconds` | После скольких секунд тишины валидатор или площадка считаются потерянными (30) |
|
| `orchestrator.heartbeat_timeout_seconds` | После скольких секунд тишины валидатор или площадка считаются потерянными (30) |
|
||||||
| `orchestrator.fip_settle_seconds` | Только начальное значение: пауза между привязкой FIP и self-check; далее управляется на лету. Должно выполняться `fip_settle_seconds + self_check_timeout_seconds < lease_ttl_seconds` |
|
| `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 — выключен). Не выполняется, пока включён автоматический цикл |
|
| `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`) |
|
| `aggregation.missing_counts_as_fail` | Отсутствие ответа источника засчитывается как провал (`true`) |
|
||||||
| `validators`, `sites`, `check_types`, `targets`, `inbound_checks` | Начальная загрузка пустой БД: валидаторы (`validator_id` + `os_port_id`), внешние площадки, типы и цели egress-проверок, порты inbound-проверок. Дальше источник истины — БД, правки через API/UI |
|
| `validators`, `sites`, `check_types`, `targets`, `inbound_checks` | Начальная загрузка пустой БД: валидаторы (`validator_id` + `os_port_id`), внешние площадки, типы и цели egress-проверок, порты inbound-проверок. Дальше источник истины — БД, правки через API/UI |
|
||||||
| `ip_addresses` | Адреса, которые доливаются в очередь при каждом старте (только новые) |
|
| `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.*`) |
|
| `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`, `poll_interval_seconds`, `checks.*` (таймауты TCP/ICMP, число ICMP-пакетов) |
|
| `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`, `overview.last_completed_count` (20), `overview.poll_interval_seconds` (5) |
|
| `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`.
|
Настройки, меняющиеся на лету (пауза перед self-check, глубина истории, типы inbound-проверок, автоматический цикл, валидаторы, площадки, цели), хранятся в БД и правятся через API или страницу `/settings`.
|
||||||
|
|
||||||
@@ -122,12 +125,20 @@ docs/ документация и планы доработок
|
|||||||
|
|
||||||
**Соглашения**
|
**Соглашения**
|
||||||
- Тело запросов и ответов — JSON. Успех — `200`, `204` — когда данных нет (например, у валидатора нет назначения).
|
- Тело запросов и ответов — 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).
|
- Времена — RFC 3339. Полная спецификация и примеры `curl` — [docs/API.md](docs/API.md).
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
- **Аутентификации API нет** — это известное ограничение текущей версии: эндпоинты, включая административные, доступны любому, кто достучится до порта `control-api`. Доступ ограничивается на уровне сети/файрвола ([docs/SETUP.md](docs/SETUP.md#сетевые-доступы)); bearer-токен — направление доработки.
|
- **Доступ к API — два статических Bearer-токена** (без срока жизни, из переменных окружения, сравнение в константное время):
|
||||||
- Дашборд — тонкий прокси к API без собственных учётных записей и состояния; публиковать его так же нужно только в доверенном сегменте или за reverse-proxy с авторизацией.
|
| Уровень | Токен | Методы |
|
||||||
|
|---|---|---|
|
||||||
|
| 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` — нет.
|
- Учётные данные OpenStack передаются только через переменные окружения процесса (`EnvironmentFile=` в systemd, `OS_*` в Docker) и не попадают в YAML; режим `password` перевыпускает токен сам, режим `token` — нет.
|
||||||
- Компоненты работают по pull-модели: на валидаторах и площадках не нужно открывать входящие порты для `control-api`.
|
- Компоненты работают по pull-модели: на валидаторах и площадках не нужно открывать входящие порты для `control-api`.
|
||||||
- Бинарники статические, без `cgo`; целостность проверяется `sha256sum -c bin/SHA256SUMS`.
|
- Бинарники статические, без `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
|
go test -race ./internal/orchestrator ./internal/httpapi ./internal/dashboard ./internal/db
|
||||||
scripts/run-local-e2e.sh # сквозной прогон: lease-reclaim, перепроверка, автоматический цикл
|
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/DASHBOARD.md](docs/DASHBOARD.md) | Устройство `admin-dashboard`: страницы, поиск и фильтр, обработка ошибок |
|
||||||
| [docs/DIAGRAMS.md](docs/DIAGRAMS.md) | Диаграммы потоков данных: control plane, egress-проверка, телеметрия |
|
| [docs/DIAGRAMS.md](docs/DIAGRAMS.md) | Диаграммы потоков данных: control plane, egress-проверка, телеметрия |
|
||||||
| [docs/LOCAL_E2E.md](docs/LOCAL_E2E.md) | Полностью офлайн-прогон всей системы одним скриптом |
|
| [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-деплоя — открыть в браузере |
|
| [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-10-01 | Автоматический цикл проверок по сценарию: очистка → скан FIP → проверка → пауза | [USAGE](docs/USAGE.md#автоматический-цикл-проверок) · [API](docs/API.md#автоматический-цикл-проверок) |
|
||||||
| 2026-09-23 | Сканирование Floating IP и устойчивый реестр адресов с настраиваемой глубиной истории | [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) |
|
| 2026-09-23 | Сканирование Floating IP и устойчивый реестр адресов с настраиваемой глубиной истории | [USAGE](docs/USAGE.md#реестр-адресов-и-глубина-истории) |
|
||||||
| 2026-09-23 | Поиск по IP и фильтр по статусу на «Обзоре» и «Реестре» | [DASHBOARD](docs/DASHBOARD.md) |
|
| 2026-09-23 | Поиск по IP и фильтр по статусу на «Обзоре» и «Реестре» | [DASHBOARD](docs/DASHBOARD.md) |
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
fb8aaab7c07c3702d274aa15fcb299690bb738cbde9faad79e73d726c24e9d1b control-api
|
a0eac5409a2929d641ef2a217c31f1b6a974a8679da866ebbb50ff1d5b57df8d control-api
|
||||||
d7377e90cc34549933f0de8556df113f7ace7425ad0fee96f723ed6a4510d66b validator-agent
|
5d7fb2f476871843ae4179dddb61e77d3f71e8eb73a00e331f0119d8893ba813 validator-agent
|
||||||
c3c9e42ad7632cd8a4b88fe5eedf341cf0dcba6ecf5281d247733a98cee91fb6 prober
|
23c687a988350c61f486d400af654bd563865fff59421c437eb679e0e4e10500 prober
|
||||||
23d1c10c26366dedd790025689eae63677316c7bb9dafb53c8cebca340dfe77f admin-dashboard
|
90921ad25f16a922124368770e439d1228c5b0bf7126ba6c726f3fb98bb22f7a admin-dashboard
|
||||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -39,11 +39,21 @@ func run(configPath string, log *slog.Logger) error {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
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{
|
srv, err := dashboard.New(dashboard.Config{
|
||||||
ControlAPIBaseURL: cfg.ControlAPI.BaseURL,
|
ControlAPIBaseURL: cfg.ControlAPI.BaseURL,
|
||||||
ControlAPITimeout: time.Duration(cfg.ControlAPI.TimeoutSeconds) * time.Second,
|
ControlAPITimeout: time.Duration(cfg.ControlAPI.TimeoutSeconds) * time.Second,
|
||||||
LastCompletedCount: cfg.Overview.LastCompletedCount,
|
LastCompletedCount: cfg.Overview.LastCompletedCount,
|
||||||
OverviewPollIntervalS: cfg.Overview.PollIntervalSeconds,
|
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)
|
}, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init dashboard: %w", err)
|
return fmt.Errorf("init dashboard: %w", err)
|
||||||
|
|||||||
+10
-1
@@ -60,7 +60,16 @@ func run(configPath string, log *slog.Logger) error {
|
|||||||
|
|
||||||
orch := orchestrator.New(database, osClient, cfg, log)
|
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()}
|
httpServer := &http.Server{Addr: cfg.Server.ListenAddr, Handler: srv.Handler()}
|
||||||
|
|
||||||
go runOrchestratorLoop(ctx, orch, cfg, log)
|
go runOrchestratorLoop(ctx, orch, cfg, log)
|
||||||
|
|||||||
+5
-1
@@ -31,7 +31,11 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
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 {
|
if err := prober.Run(ctx); err != nil && err != context.Canceled {
|
||||||
log.Error("prober stopped", "err", err)
|
log.Error("prober stopped", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ func main() {
|
|||||||
startStubListeners(ctx, log, *stubPorts)
|
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 {
|
if err := agent.Run(ctx); err != nil && err != context.Canceled {
|
||||||
log.Error("agent stopped", "err", err)
|
log.Error("agent stopped", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ server:
|
|||||||
control_api:
|
control_api:
|
||||||
base_url: "http://control-api.internal:8080"
|
base_url: "http://control-api.internal:8080"
|
||||||
timeout_seconds: 10
|
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. Оба поля
|
# Настройки сводки на странице "Обзор" — см. docs/DASHBOARD.md. Оба поля
|
||||||
# влияют только на то, как дашборд группирует уже существующие данные
|
# влияют только на то, как дашборд группирует уже существующие данные
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ openstack:
|
|||||||
user_domain_name_env: "OS_USER_DOMAIN_NAME"
|
user_domain_name_env: "OS_USER_DOMAIN_NAME"
|
||||||
password_env: "OS_PASSWORD"
|
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:
|
orchestrator:
|
||||||
poll_interval_seconds: 5
|
poll_interval_seconds: 5
|
||||||
self_check_timeout_seconds: 60
|
self_check_timeout_seconds: 60
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
site_id: "site-1"
|
site_id: "site-1"
|
||||||
control_api_url: "http://control-api.internal:8080"
|
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
|
poll_interval_seconds: 5
|
||||||
|
|
||||||
checks:
|
checks:
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
|
|
||||||
validator_id: "validator_01"
|
validator_id: "validator_01"
|
||||||
control_api_url: "http://control-api.internal:8080"
|
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
|
poll_interval_seconds: 5
|
||||||
|
|
||||||
self_check:
|
self_check:
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ PROBER_SITE_ID=site-1
|
|||||||
# --- validator-agent (must match a validator_id in the mounted control-api config) ---
|
# --- validator-agent (must match a validator_id in the mounted control-api config) ---
|
||||||
VALIDATOR_AGENT_VALIDATOR_ID=validator_01
|
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 /
|
# ADMIN_DASHBOARD_CONTROL_API_URL / PROBER_CONTROL_API_URL /
|
||||||
# VALIDATOR_AGENT_CONTROL_API_URL default to http://control-api:8080 in
|
# VALIDATOR_AGENT_CONTROL_API_URL default to http://control-api:8080 in
|
||||||
# docker-compose.yml (same Docker network) — only set here to override.
|
# docker-compose.yml (same Docker network) — only set here to override.
|
||||||
@@ -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 (only needed on a dashboard-profile host) ---
|
||||||
ADMIN_DASHBOARD_CONTROL_API_URL=http://control-api:8080
|
ADMIN_DASHBOARD_CONTROL_API_URL=http://control-api:8080
|
||||||
|
|
||||||
|
# --- authentication (fill in only what this host needs; empty = open + warning) ---
|
||||||
|
# Generate each secret with: openssl rand -hex 32
|
||||||
|
# Rollout order without downtime: update all binaries first, then give the
|
||||||
|
# tokens to prober / validator-agent / dashboard, and set the control-api
|
||||||
|
# tokens (and restart it) last.
|
||||||
|
#
|
||||||
|
# control-plane host: tokens that control-api enforces
|
||||||
|
CONTROL_API_ADMIN_TOKEN=
|
||||||
|
CONTROL_API_AGENT_TOKEN=
|
||||||
|
# dashboard host: control-api's admin token, the login, and the cookie-signing key
|
||||||
|
ADMIN_DASHBOARD_CONTROL_API_TOKEN=
|
||||||
|
ADMIN_DASHBOARD_USERNAME=
|
||||||
|
ADMIN_DASHBOARD_PASSWORD=
|
||||||
|
ADMIN_DASHBOARD_SESSION_SECRET=
|
||||||
|
# prober / validator hosts use CONTROL_API_AGENT_TOKEN (same value as above)
|
||||||
@@ -25,6 +25,7 @@ docker run -d --platform linux/amd64 --cap-add NET_RAW --name prober \
|
|||||||
-e PROBER_TCP_TIMEOUT_SECONDS=5 \
|
-e PROBER_TCP_TIMEOUT_SECONDS=5 \
|
||||||
-e PROBER_ICMP_TIMEOUT_SECONDS=5 \
|
-e PROBER_ICMP_TIMEOUT_SECONDS=5 \
|
||||||
-e PROBER_ICMP_COUNT=3 \
|
-e PROBER_ICMP_COUNT=3 \
|
||||||
|
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
|
||||||
cloud-ip-validator-prober
|
cloud-ip-validator-prober
|
||||||
|
|
||||||
Рабочий пример (стенд 62.176.10.113):
|
Рабочий пример (стенд 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_TCP_TIMEOUT_SECONDS опциональная, дефолт 5
|
||||||
PROBER_ICMP_TIMEOUT_SECONDS опциональная, дефолт 5
|
PROBER_ICMP_TIMEOUT_SECONDS опциональная, дефолт 5
|
||||||
PROBER_ICMP_COUNT опциональная, дефолт 3
|
PROBER_ICMP_COUNT опциональная, дефолт 3
|
||||||
|
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api (то же
|
||||||
|
значение, что у control-api); без него результаты
|
||||||
|
проверок будут отклонены, если токен включён
|
||||||
|
|
||||||
Примечания:
|
Примечания:
|
||||||
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
--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_CONTROL_API_TIMEOUT_SECONDS=10 \
|
||||||
-e ADMIN_DASHBOARD_LAST_COMPLETED_COUNT=20 \
|
-e ADMIN_DASHBOARD_LAST_COMPLETED_COUNT=20 \
|
||||||
-e ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS=5 \
|
-e ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS=5 \
|
||||||
|
-e ADMIN_DASHBOARD_CONTROL_API_TOKEN=<admin_token> \
|
||||||
|
-e ADMIN_DASHBOARD_USERNAME=<login> \
|
||||||
|
-e ADMIN_DASHBOARD_PASSWORD=<password> \
|
||||||
|
-e ADMIN_DASHBOARD_SESSION_SECRET=<random_hex> \
|
||||||
cloud-ip-validator-admin-dashboard
|
cloud-ip-validator-admin-dashboard
|
||||||
|
|
||||||
Рабочий пример (стенд 62.176.10.113):
|
Рабочий пример (стенд 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_CONTROL_API_TIMEOUT_SECONDS опциональная, дефолт 10
|
||||||
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT опциональная, дефолт 20
|
ADMIN_DASHBOARD_LAST_COMPLETED_COUNT опциональная, дефолт 20
|
||||||
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS опциональная, дефолт 5
|
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
|
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
||||||
@@ -102,12 +118,18 @@ docker run -d --platform linux/amd64 -p 8080:8080 --name control-api \
|
|||||||
-e OS_PROJECT_ID=<project_id> \
|
-e OS_PROJECT_ID=<project_id> \
|
||||||
-e OS_REGION_NAME=<region> \
|
-e OS_REGION_NAME=<region> \
|
||||||
-e OS_TOKEN=<token> \
|
-e OS_TOKEN=<token> \
|
||||||
|
-e CONTROL_API_ADMIN_TOKEN=<admin_token> \
|
||||||
|
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
|
||||||
cloud-ip-validator-control-api
|
cloud-ip-validator-control-api
|
||||||
|
|
||||||
(при auth_method: password — вместо OS_TOKEN передать OS_USERNAME,
|
(при auth_method: password — вместо OS_TOKEN передать OS_USERNAME,
|
||||||
OS_USER_DOMAIN_NAME, OS_PASSWORD; для openstack.mode: mock креденшлы не
|
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
|
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
||||||
-p 8080:8080 проброс порта control-api наружу
|
-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_ICMP_COUNT=3 \
|
||||||
-e VALIDATOR_AGENT_SSH_ENABLED=false \
|
-e VALIDATOR_AGENT_SSH_ENABLED=false \
|
||||||
-e VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5 \
|
-e VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS=5 \
|
||||||
|
-e CONTROL_API_AGENT_TOKEN=<agent_token> \
|
||||||
cloud-ip-validator-validator-agent
|
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_ICMP_COUNT опциональная, дефолт 3
|
||||||
VALIDATOR_AGENT_SSH_ENABLED опциональная, дефолт false
|
VALIDATOR_AGENT_SSH_ENABLED опциональная, дефолт false
|
||||||
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS опциональная, дефолт 5
|
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS опциональная, дефолт 5
|
||||||
|
CONTROL_API_AGENT_TOKEN опциональная: токен агентов control-api
|
||||||
|
(то же значение, что у control-api)
|
||||||
|
|
||||||
Примечания:
|
Примечания:
|
||||||
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
--platform linux/amd64 бинарник в bin/ собран под Linux x86_64
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS="${ADMIN_DASHBOARD_CONTROL_AP
|
|||||||
export ADMIN_DASHBOARD_LAST_COMPLETED_COUNT="${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
|
export ADMIN_DASHBOARD_LAST_COMPLETED_COUNT="${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
|
||||||
export ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS="${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
|
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}' \
|
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
|
< /etc/admin-dashboard/admin-dashboard.yaml.tmpl > /etc/admin-dashboard/admin-dashboard.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ database:
|
|||||||
openstack:
|
openstack:
|
||||||
mode: "mock"
|
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:
|
orchestrator:
|
||||||
poll_interval_seconds: 5
|
poll_interval_seconds: 5
|
||||||
self_check_timeout_seconds: 60
|
self_check_timeout_seconds: 60
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ services:
|
|||||||
networks: [backend]
|
networks: [backend]
|
||||||
volumes:
|
volumes:
|
||||||
- control-api-db:/var/lib/cloud-ip-validator
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8080/healthz"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -55,6 +60,12 @@ services:
|
|||||||
ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS: "${ADMIN_DASHBOARD_CONTROL_API_TIMEOUT_SECONDS:-10}"
|
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_LAST_COMPLETED_COUNT: "${ADMIN_DASHBOARD_LAST_COMPLETED_COUNT:-20}"
|
||||||
ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS: "${ADMIN_DASHBOARD_POLL_INTERVAL_SECONDS:-5}"
|
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:
|
prober:
|
||||||
build:
|
build:
|
||||||
@@ -72,6 +83,8 @@ services:
|
|||||||
PROBER_TCP_TIMEOUT_SECONDS: "${PROBER_TCP_TIMEOUT_SECONDS:-5}"
|
PROBER_TCP_TIMEOUT_SECONDS: "${PROBER_TCP_TIMEOUT_SECONDS:-5}"
|
||||||
PROBER_ICMP_TIMEOUT_SECONDS: "${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
|
PROBER_ICMP_TIMEOUT_SECONDS: "${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
|
||||||
PROBER_ICMP_COUNT: "${PROBER_ICMP_COUNT:-3}"
|
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:
|
validator-agent:
|
||||||
build:
|
build:
|
||||||
@@ -92,3 +105,5 @@ services:
|
|||||||
VALIDATOR_AGENT_ICMP_COUNT: "${VALIDATOR_AGENT_ICMP_COUNT:-3}"
|
VALIDATOR_AGENT_ICMP_COUNT: "${VALIDATOR_AGENT_ICMP_COUNT:-3}"
|
||||||
VALIDATOR_AGENT_SSH_ENABLED: "${VALIDATOR_AGENT_SSH_ENABLED:-false}"
|
VALIDATOR_AGENT_SSH_ENABLED: "${VALIDATOR_AGENT_SSH_ENABLED:-false}"
|
||||||
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
|
VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS: "${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
|
||||||
|
# Agent token of control-api (same value as on control-api).
|
||||||
|
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
|
||||||
@@ -9,6 +9,10 @@ export PROBER_TCP_TIMEOUT_SECONDS="${PROBER_TCP_TIMEOUT_SECONDS:-5}"
|
|||||||
export PROBER_ICMP_TIMEOUT_SECONDS="${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
|
export PROBER_ICMP_TIMEOUT_SECONDS="${PROBER_ICMP_TIMEOUT_SECONDS:-5}"
|
||||||
export PROBER_ICMP_COUNT="${PROBER_ICMP_COUNT:-3}"
|
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}' \
|
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
|
< /etc/prober/prober.yaml.tmpl > /etc/prober/prober.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export VALIDATOR_AGENT_ICMP_COUNT="${VALIDATOR_AGENT_ICMP_COUNT:-3}"
|
|||||||
export VALIDATOR_AGENT_SSH_ENABLED="${VALIDATOR_AGENT_SSH_ENABLED:-false}"
|
export VALIDATOR_AGENT_SSH_ENABLED="${VALIDATOR_AGENT_SSH_ENABLED:-false}"
|
||||||
export VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS="${VALIDATOR_AGENT_SSH_TIMEOUT_SECONDS:-5}"
|
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}' \
|
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
|
< /etc/validator-agent/validator-agent.yaml.tmpl > /etc/validator-agent/validator-agent.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ Type=simple
|
|||||||
User=cloud-ip-validator
|
User=cloud-ip-validator
|
||||||
Group=cloud-ip-validator
|
Group=cloud-ip-validator
|
||||||
ExecStart=/usr/local/bin/admin-dashboard -config /etc/cloud-ip-validator/admin-dashboard.yaml
|
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
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -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: token) or OS_USERNAME / OS_USER_DOMAIN_NAME / OS_PASSWORD
|
||||||
# (auth_method: password) — see docs/SETUP.md. Keep this file mode 0600,
|
# (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
|
# 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
|
EnvironmentFile=/etc/cloud-ip-validator/control-api.env
|
||||||
WorkingDirectory=/var/lib/cloud-ip-validator
|
WorkingDirectory=/var/lib/cloud-ip-validator
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Type=simple
|
|||||||
User=cloud-ip-validator
|
User=cloud-ip-validator
|
||||||
Group=cloud-ip-validator
|
Group=cloud-ip-validator
|
||||||
ExecStart=/usr/local/bin/prober -config /etc/cloud-ip-validator/prober.yaml
|
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
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ Type=simple
|
|||||||
User=cloud-ip-validator
|
User=cloud-ip-validator
|
||||||
Group=cloud-ip-validator
|
Group=cloud-ip-validator
|
||||||
ExecStart=/usr/local/bin/validator-agent -config /etc/cloud-ip-validator/validator-agent.yaml
|
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
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
+33
-9
@@ -4,15 +4,13 @@ Control API — единственная точка входа в систему
|
|||||||
`prober` и оператора (администратора). Все данные передаются в формате
|
`prober` и оператора (администратора). Все данные передаются в формате
|
||||||
JSON, базовый префикс прикладных методов — `/api/v1`.
|
JSON, базовый префикс прикладных методов — `/api/v1`.
|
||||||
|
|
||||||
> **Важно.** На данный момент API не защищён аутентификацией/авторизацией
|
> **Аутентификация.** Доступ к API определяется двумя статическими
|
||||||
> — эндпоинты доступны любому, кто может достучаться до порта control-api
|
> bearer-токенами — см. [«Аутентификация»](#аутентификация). Токен задаётся
|
||||||
> по сети. Это касается и методов из раздела
|
> переменной окружения; **если токен не задан, соответствующий уровень остаётся
|
||||||
> [«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией)
|
> открытым** (control-api стартует с предупреждением в логе) — так сделано для
|
||||||
> ниже — они меняют, что и как проверяется, без подтверждения личности
|
> обратной совместимости. Поэтому для эксплуатации за пределами доверенного
|
||||||
> вызывающего. Для эксплуатации за пределами доверенного сегмента сети
|
> сегмента сети токены нужно задать, а доступ дополнительно ограничить на уровне
|
||||||
> обязательно ограничьте доступ на уровне сети/файрвола (см.
|
> сети/файрвола (см. [SETUP.md](SETUP.md#сетевые-доступы)).
|
||||||
> [SETUP.md](SETUP.md#сетевые-доступы)). Добавление bearer-токена — известное
|
|
||||||
> направление доработки, в текущей версии не реализовано.
|
|
||||||
|
|
||||||
Базовый URL в примерах — `http://control-api.internal:8080`, замените на
|
Базовый URL в примерах — `http://control-api.internal:8080`, замените на
|
||||||
адрес вашего стенда (см. `server.listen_addr` в конфиге control-api).
|
адрес вашего стенда (см. `server.listen_addr` в конфиге control-api).
|
||||||
@@ -23,6 +21,7 @@ JSON, базовый префикс прикладных методов — `/ap
|
|||||||
|
|
||||||
## Содержание
|
## Содержание
|
||||||
|
|
||||||
|
- [Аутентификация](#аутентификация)
|
||||||
- [Общие соглашения](#общие-соглашения)
|
- [Общие соглашения](#общие-соглашения)
|
||||||
- [Методы для validator-agent](#методы-для-validator-agent)
|
- [Методы для validator-agent](#методы-для-validator-agent)
|
||||||
- [Методы для prober](#методы-для-prober)
|
- [Методы для prober](#методы-для-prober)
|
||||||
@@ -33,6 +32,31 @@ JSON, базовый префикс прикладных методов — `/ap
|
|||||||
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
|
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
|
||||||
- [Сквозной пример работы (curl)](#сквозной-пример-работы-curl)
|
- [Сквозной пример работы (curl)](#сквозной-пример-работы-curl)
|
||||||
|
|
||||||
|
## Аутентификация
|
||||||
|
|
||||||
|
Токен передаётся заголовком `Authorization: Bearer <токен>`. Токены статические, **без срока жизни**; ротация — смена
|
||||||
|
переменной окружения и перезапуск. Сравнение выполняется в константное время.
|
||||||
|
|
||||||
|
| Уровень | Токен (переменная на control-api) | Какие методы |
|
||||||
|
|---|---|---|
|
||||||
|
| **admin** | `CONTROL_API_ADMIN_TOKEN` | все `/api/v1/admin/*` (очередь, реестр, автоцикл, `config/*`) |
|
||||||
|
| **agent** | `CONTROL_API_AGENT_TOKEN` | запись результатов: `POST /agents/{id}/self-check`, `/events`, `/results`, `/complete` и `POST /probers/{site_id}/results` |
|
||||||
|
| **открыто** | — | `GET /healthz`; `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`; `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments` |
|
||||||
|
|
||||||
|
- Токены разные: токен администратора **не** подходит для методов агентов, и наоборот.
|
||||||
|
- Валидатор и пробер могут без токена зарегистрироваться, слать heartbeat и забирать задание (настройку); отправка результатов без токена агентов — `401`.
|
||||||
|
- Имена переменных меняются в секции `auth` конфига control-api (`admin_token_env`, `agent_token_env`); сами значения в YAML не хранятся.
|
||||||
|
- Ответ при отказе: `401 {"error": "unauthorized"}` с заголовком `WWW-Authenticate: Bearer`.
|
||||||
|
- Токен не задан (пустая переменная) — уровень открыт; в логе control-api при старте предупреждение. Токены нужно генерировать случайными: `openssl rand -hex 32`.
|
||||||
|
- Токены уходят открытым текстом, если TLS не терминируется перед control-api, — публикуйте API через reverse-proxy с TLS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ADMIN_TOKEN=... # значение CONTROL_API_ADMIN_TOKEN
|
||||||
|
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://<control-api>:8080/api/v1/admin/status
|
||||||
|
```
|
||||||
|
|
||||||
|
> Во всех примерах `curl` ниже заголовок `Authorization` для краткости опущен; если токен администратора задан, добавляйте его к методам `/api/v1/admin/*`.
|
||||||
|
|
||||||
## Общие соглашения
|
## Общие соглашения
|
||||||
|
|
||||||
- Тело запроса и ответа — JSON (`Content-Type: application/json`).
|
- Тело запроса и ответа — JSON (`Content-Type: application/json`).
|
||||||
|
|||||||
@@ -157,6 +157,26 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
|
|||||||
циклов) остаётся всегда. Подробнее —
|
циклов) остаётся всегда. Подробнее —
|
||||||
[API.md](API.md#реестр-адресов-и-история-проверок).
|
[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`. Ключевые поля:
|
См. `configs/admin-dashboard.example.yaml`. Ключевые поля:
|
||||||
@@ -168,6 +188,11 @@ auto-refresh на `/ips`, см. git-историю). Опрашивается т
|
|||||||
на странице обзора.
|
на странице обзора.
|
||||||
- `overview.poll_interval_seconds` — как часто браузер опрашивает
|
- `overview.poll_interval_seconds` — как часто браузер опрашивает
|
||||||
`/overview/fragment` для live-обновления.
|
`/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). Подробности — [«Вход и сессия»](#вход-и-сессия).
|
||||||
|
|
||||||
## Отображение ошибок
|
## Отображение ошибок
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,35 @@ cp configs/prober.example.yaml /etc/cloud-ip-validator/prober.yaml
|
|||||||
- `control_api_url` — адрес control-api, доступный с площадки (обычно
|
- `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
|
## Развёртывание control-api
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
[API.md](API.md) — здесь мы используем их только как инструмент, не
|
[API.md](API.md) — здесь мы используем их только как инструмент, не
|
||||||
углубляясь в протокол.
|
углубляясь в протокол.
|
||||||
|
|
||||||
|
> **Токен в примерах.** Если на стенде включена аутентификация
|
||||||
|
> ([SETUP.md](SETUP.md#5-аутентификация-токены-и-пароль-дашборда)), к вызовам
|
||||||
|
> `/api/v1/admin/*` в примерах `curl` ниже нужно добавлять заголовок
|
||||||
|
> `-H "Authorization: Bearer $ADMIN_TOKEN"` (значение `CONTROL_API_ADMIN_TOKEN`);
|
||||||
|
> для краткости он опущен. Дашборд запрашивает логин и пароль.
|
||||||
|
|
||||||
## Содержание
|
## Содержание
|
||||||
|
|
||||||
- [Как устроена работа с системой](#как-устроена-работа-с-системой)
|
- [Как устроена работа с системой](#как-устроена-работа-с-системой)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# План: аутентификация API и UI
|
||||||
|
|
||||||
|
> Дата: 2026-10-01 11:12 MSK · Статус: **реализовано** — результаты ревью и тестов: [2026-10-01_11-31_authentication-review.md](2026-10-01_11-31_authentication-review.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Сейчас ни `control-api`, ни `admin-dashboard` не имеют аутентификации (прямо сказано в `docs/API.md`, раздел «Важно»):
|
||||||
|
любой, кто достучится до порта, может менять очередь и конфигурацию, а в дашборд зайти без пароля. Нужно:
|
||||||
|
1. закрыть **ручки управления** (`/api/v1/admin/*`) токеном администратора;
|
||||||
|
2. оставить `validator-agent` и `prober` возможность **забрать свою настройку/задание без аутентификации**, но не дать
|
||||||
|
подделывать результаты проверок — записывающие вызовы закрыть **отдельным токеном агентов** (без срока жизни);
|
||||||
|
3. закрыть UI **логином и паролем** (сессия по cookie).
|
||||||
|
|
||||||
|
Решения пользователя: вариант «открыты только получение настройки/задания», токен агентов отдельный от админского и
|
||||||
|
бессрочный (статический, из env); `control-api` без токена **стартует с предупреждением** в логе (обратная совместимость);
|
||||||
|
пароль администратора дашборда — в env, сравнение в константное время.
|
||||||
|
|
||||||
|
## Карта кодовой базы (по графу, сверено с кодом)
|
||||||
|
|
||||||
|
Единые точки врезки — их мало, поэтому изменения локальны:
|
||||||
|
- `httpapi.Server.Handler()` (`internal/httpapi/server.go:29`) — один `mux` + `loggingMiddleware`; все 45 маршрутов в
|
||||||
|
`internal/httpapi/routes.go`: 33 admin, 7 agents, 4 probers, `GET /healthz`.
|
||||||
|
- `apiclient.Client.Do` (`internal/apiclient/apiclient.go`) — единственный HTTP-клиент `agentcore` (`internal/agentcore/agentcore.go`)
|
||||||
|
и `probercore` (`internal/probercore/probercore.go`) к control-api. Запросы к внешним целям/IP-echo идут через
|
||||||
|
`http.DefaultClient`/`checkrunner` — токен туда попасть **не должен**.
|
||||||
|
- `dashboard.client.do` (`internal/dashboard/client.go:48`) — единственный клиент дашборда к control-api (не `apiclient`).
|
||||||
|
- `dashboard.Server.Handler()` (`internal/dashboard/server.go:43`) — один `mux` + `loggingMiddleware`; маршруты в
|
||||||
|
`internal/dashboard/routes.go`; статика `GET /static/`; htmx опрашивает `/overview/fragment` каждые N секунд.
|
||||||
|
- Конфиг: `internal/config/config.go` (`ControlAPI`, `AdminDashboard`/`DashboardControlAPIConfig`, агент/пробер),
|
||||||
|
`Load*` с дефолтами «if x == 0». Секреты уже передаются **именами env-переменных** (`openstack.*_env`) — тот же приём.
|
||||||
|
- `go.mod` без `x/crypto`; `crypto/subtle`, `crypto/hmac`, `crypto/sha256` — из стандартной библиотеки, новых зависимостей нет.
|
||||||
|
|
||||||
|
## Классификация маршрутов (45)
|
||||||
|
|
||||||
|
| Доступ | Маршруты |
|
||||||
|
|---|---|
|
||||||
|
| **admin-токен** (33) | все `/api/v1/admin/*`: status, ips (+scan/clear/delete/cancel/{ip}), validators, registry, auto-cycle ×4, config/* |
|
||||||
|
| **agent-токен** (5) | `POST /agents/{id}/self-check`, `/events`, `/results`, `/complete`; `POST /probers/{site_id}/results` |
|
||||||
|
| **открыто** (7) | `GET /healthz`; `POST /agents/register`, `POST /agents/{id}/heartbeat`, `GET /agents/{id}/assignment`; `POST /probers/register`, `POST /probers/{site_id}/heartbeat`, `GET /probers/{site_id}/assignments` |
|
||||||
|
|
||||||
|
Токены разные: admin-токен **не** открывает agent-маршруты и наоборот. Heartbeat оставлен открытым, как в выбранном варианте
|
||||||
|
(риск: подделка heartbeat «оживляет» упавший валидатор; при желании переносится под agent-токен одной строкой в таблице).
|
||||||
|
|
||||||
|
## Дизайн
|
||||||
|
|
||||||
|
### 1. control-api (`internal/httpapi`, `internal/config`, `cmd/control-api`)
|
||||||
|
- **Таблица маршрутов с обязательным уровнем доступа.** `routes.go` переписывается на таблицу
|
||||||
|
`[]route{pattern, handler, access}` (`accessOpen|accessAgent|accessAdmin`) — поле обязательное, забыть защитить новый
|
||||||
|
маршрут нельзя; одна и та же таблица используется в тесте покрытия.
|
||||||
|
- `internal/httpapi/auth.go`: `Authenticator{AdminToken, AgentToken string}`; `require(access, h)` читает
|
||||||
|
`Authorization: Bearer …`, сравнивает через `sha256` + `subtle.ConstantTimeCompare`; при несовпадении — `401`
|
||||||
|
`{"error":"unauthorized"}` + `WWW-Authenticate: Bearer`, в лог — метод/путь/remote без токена. Пустой токен соответствующего
|
||||||
|
уровня ⇒ проверка этого уровня отключена (совместимость, как выбрал пользователь).
|
||||||
|
- `Server` получает поле `Auth`, выставляемое методом `WithAuth(admin, agent)`; `httpapi.New(...)` не меняется ⇒ существующие
|
||||||
|
тесты и `newConfigTestHarness` работают без токенов.
|
||||||
|
- Конфиг: секция `auth:` в `control-api.yaml` — `admin_token_env` (по умолчанию `CONTROL_API_ADMIN_TOKEN`) и
|
||||||
|
`agent_token_env` (`CONTROL_API_AGENT_TOKEN`); значения читаются из env в `run()`, в YAML секретов нет.
|
||||||
|
- `cmd/control-api/main.go`: при пустом токене — `log.Warn("admin API is open: CONTROL_API_ADMIN_TOKEN is not set")`
|
||||||
|
(аналогично для agent); значения токенов в логи не попадают.
|
||||||
|
|
||||||
|
### 2. validator-agent и prober (`internal/apiclient`, `internal/agentcore`, `internal/probercore`, `internal/config`)
|
||||||
|
- `apiclient.Client` получает поле `Token`; `Do` добавляет `Authorization: Bearer <token>` ко **всем** запросам к control-api
|
||||||
|
(на открытых маршрутах он безвреден). `apiclient.New` не меняется (поле выставляется отдельно).
|
||||||
|
- Конфиги агента/пробера: `control_api_token_env` (по умолчанию `CONTROL_API_AGENT_TOKEN`); читается в `cmd/*/main.go`.
|
||||||
|
Токен не попадает в `fetchIPEcho`/проверки (они используют отдельные клиенты).
|
||||||
|
- Агент и пробер продолжают без токена регистрироваться и получать задания; результаты без токена получат `401` и будут
|
||||||
|
залогированы существующим кодом обработки ошибок.
|
||||||
|
|
||||||
|
### 3. admin-dashboard (`internal/dashboard`, `internal/config`, `cmd/admin-dashboard`)
|
||||||
|
- **Токен к control-api:** `control_api.token_env` (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`); `client.do` добавляет Bearer.
|
||||||
|
Ответ `401/403` от API отображается существующим баннером (`apiErr`, `bannerFor`).
|
||||||
|
- **Логин и пароль:** секция `auth:` — `username_env`, `password_env`, `session_secret_env`, `session_ttl_minutes` (480).
|
||||||
|
Учётные данные сравниваются через `sha256` + `subtle.ConstantTimeCompare`. Если логин/пароль не заданы — вход не требуется,
|
||||||
|
в лог предупреждение (как политика control-api).
|
||||||
|
- `internal/dashboard/auth.go`: middleware вокруг `mux` в `Server.Handler()`:
|
||||||
|
- открыто: `GET|POST /login`, `GET /static/*`; всё остальное требует валидной сессии;
|
||||||
|
- без сессии: обычный запрос → `303 /login?next=…`; **htmx-запрос** (`HX-Request`) → `401` + `HX-Redirect: /login`, чтобы
|
||||||
|
опрос `/overview/fragment` не подставлял страницу входа внутрь фрагмента;
|
||||||
|
- **сессия без состояния** (дашборд остаётся stateless): cookie `session = base64(payload).hmac`, payload `{user, exp}`,
|
||||||
|
HMAC-SHA256 ключом из `session_secret_env` (если не задан — случайный на старте + предупреждение: сессии сбрасываются
|
||||||
|
рестартом); `HttpOnly`, `SameSite=Strict`, `Secure` при HTTPS (`X-Forwarded-Proto`/TLS);
|
||||||
|
- **CSRF** для `POST/PUT/DELETE`: проверка `Origin`/`Referer` на совпадение с `Host` (+ `SameSite=Strict`); токены в
|
||||||
|
шаблонах не нужны — htmx-формы не меняются;
|
||||||
|
- **защита от перебора**: счётчик неудачных входов по IP в памяти (5 за 10 минут → `429` с `Retry-After`);
|
||||||
|
- `POST /logout` — стирает cookie; пункт «Выйти» и имя пользователя в `sidebar_nav` (`templates/layout.html`).
|
||||||
|
- `templates/login.html` — страница входа в стиле существующих (`html_head`, `.panel`, `.field`, `btn-primary`), баннер ошибки
|
||||||
|
«Неверный логин или пароль». `loggingMiddleware` не логирует cookie и тело формы.
|
||||||
|
|
||||||
|
### 4. Развёртывание и конфигурация
|
||||||
|
- Примеры: `configs/control-api.example.yaml` (`auth`), `admin-dashboard.example.yaml`, `validator-agent.example.yaml`,
|
||||||
|
`prober.example.yaml`; копии в `rxprod-compose/sources/` и `deploy/docker/control-api/control-api.docker.example.yaml`.
|
||||||
|
- Docker: `docker-entrypoint.sh` + `*.yaml.tmpl` дашборда/агента/пробера (whitelist `envsubst` + новые переменные),
|
||||||
|
`deploy/docker/docker-compose*.yml`, `.env.example`/`.env.prod.example`, `RUN.txt`; `rxprod-compose/docker-compose.yml`
|
||||||
|
(прокидывает env; реальные секреты — в gitignored `.env`).
|
||||||
|
- systemd: `EnvironmentFile=` для `validator-agent`, `prober`, `admin-dashboard` (сейчас только у `control-api`).
|
||||||
|
- Порядок раскатки без простоя: (1) обновить все бинарники — токены не заданы, API открыт; (2) выдать токены агентам, пробером
|
||||||
|
и дашборду; (3) последним задать токены в `control-api` и перезапустить. Генерация: `openssl rand -hex 32`.
|
||||||
|
- Токены по HTTP передаются открытым текстом — в документации рекомендация TLS на reverse-proxy; ротация = смена env + рестарт.
|
||||||
|
|
||||||
|
### 5. Тесты (plain `testing`, стиль существующих)
|
||||||
|
- `internal/httpapi`: тест по **той же таблице маршрутов** — для каждого маршрута матрица {без токена, чужой токен, верный}
|
||||||
|
× уровни; проверка, что admin-токен не открывает agent-маршруты и наоборот; открытые маршруты работают без токена;
|
||||||
|
`/healthz` открыт; пустые токены ⇒ всё открыто; сквозной `TestEndToEndHTTPFlow` с токенами.
|
||||||
|
- `internal/apiclient`: заголовок Bearer ставится, без токена — нет; `agentcore`/`probercore`: токен уходит только в control-api.
|
||||||
|
- `internal/dashboard`: редирект на `/login`; `HX-Redirect` для htmx; вход верный/неверный; cookie-флаги; подделанная и
|
||||||
|
просроченная cookie; выход; CSRF по `Origin`; throttle; `/static/*` открыт; токен уходит в control-api (fake API проверяет
|
||||||
|
заголовок); при незаданных учётных данных вход не требуется.
|
||||||
|
- `scripts/run-local-e2e.sh`: токены для control-api/агента/пробера; проверки: admin-ручка без токена → `401`, с токеном → `200`;
|
||||||
|
`POST …/results` без токена → `401`; `register`/`assignment` без токена работают; весь прогон проходит с токенами.
|
||||||
|
|
||||||
|
### 6. Документация
|
||||||
|
`docs/API.md` (заменить блок «Важно»: схема токенов, таблица доступа по маршрутам, `401`), `docs/SETUP.md` (переменные, генерация
|
||||||
|
токенов, порядок раскатки), `docs/USAGE.md` (в curl-примерах — заголовок `Authorization`, общая пометка в начале), `docs/DASHBOARD.md`
|
||||||
|
(вход, сессия, выход, ключи `auth.*`), `docs/DIAGRAMS.md` (границы доступа), `docs/LOCAL_E2E.md`, `README.md` (разделы
|
||||||
|
«Конфигурация» и «Безопасность»). После реализации — пересборка `bin/` + `SHA256SUMS` и обновление графа graphify.
|
||||||
|
|
||||||
|
## Допущения (проверьте при утверждении)
|
||||||
|
- **Один общий токен агентов** для `validator-agent` и `prober` (отличный от admin-токена). Если нужны раздельные токены для
|
||||||
|
валидаторов и проберов — добавляется второй уровень доступа в ту же таблицу.
|
||||||
|
- Политика «стартовать с предупреждением» распространена и на дашборд (без заданных логина/пароля вход не требуется).
|
||||||
|
Строгий режим (отказ стартовать) — отдельное небольшое изменение.
|
||||||
|
- Один пользователь-администратор дашборда; многопользовательность и роли не вводятся.
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
1. `go build ./... && go vet ./... && go test ./...` и `go test -race` для `httpapi`, `dashboard`, `apiclient`.
|
||||||
|
2. `scripts/run-local-e2e.sh` с токенами: `401` без токена на admin- и agent-write-ручках, штатный проход цикла с токенами.
|
||||||
|
3. Вручную (curl): `GET /api/v1/admin/status` без токена → `401`, с `Bearer $ADMIN` → `200`; `POST /agents/{id}/results` с admin-токеном
|
||||||
|
→ `401`; `GET /agents/{id}/assignment` без токена → `200/204`.
|
||||||
|
4. Вручную (браузер): дашборд редиректит на `/login`; неверный пароль → ошибка, 5 неудач → `429`; после входа «Обзор» обновляется
|
||||||
|
без перезагрузки; «Выйти» возвращает на `/login`; через 8 часов (или `session_ttl_minutes`) сессия истекает.
|
||||||
|
5. Раскатка по шагам из раздела 4 на стенде `rxprod-compose`: сервисы не теряют регистрацию в момент включения токенов.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Ревью и тестирование: аутентификация API и UI
|
||||||
|
|
||||||
|
> Дата: 2026-10-01 11:31 MSK · План: [2026-10-01_11-12_authentication-plan.md](2026-10-01_11-12_authentication-plan.md)
|
||||||
|
> Статус: **реализовано и проверено на живом окружении**; токен агентов на живом стенде **намеренно не включён** (см. «Состояние живого стенда»).
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Реализовано по плану: ручки управления закрыты токеном администратора, записывающие вызовы валидатора и пробера — отдельным
|
||||||
|
бессрочным токеном агентов, получение настройки и задания остаётся открытым, дашборд закрыт логином и паролем (сессия в подписанной cookie).
|
||||||
|
Код написан отдельным агентом (Sonnet 5.5), ревью и все проверки выполнены независимо (Sonnet 5.5, high).
|
||||||
|
Найден и исправлен один реальный дефект (вёрстка страницы входа); остальные замечания — ограничения дизайна, перечислены ниже.
|
||||||
|
|
||||||
|
## Что реализовано
|
||||||
|
|
||||||
|
| Область | Изменения |
|
||||||
|
|---|---|
|
||||||
|
| control-api | `internal/httpapi/routes.go` — таблица маршрутов `{pattern, handler, access}` с **обязательным** уровнем доступа (33 admin, 5 agent, 7 open); `internal/httpapi/auth.go` — `Authenticator`, Bearer, сравнение `sha256` + `subtle.ConstantTimeCompare`, `401` + `WWW-Authenticate: Bearer`, отказы пишутся в лог без токена; `Server.WithAuth`; `config.auth.{admin_token_env,agent_token_env}` |
|
||||||
|
| validator-agent, prober | `apiclient.Client.Token` — Bearer на каждом запросе к control-api; `WithToken` в `agentcore`/`probercore`; `control_api_token_env`. Токен не попадает в IP-echo и проверки |
|
||||||
|
| admin-dashboard | `internal/dashboard/auth.go` — вход по логину/паролю, сессия без состояния (HMAC-SHA256), CSRF по `Origin`/`Referer`, защита от перебора, `HX-Redirect` для htmx, защита от open-redirect в `next`; `templates/login.html`; «Выйти» и имя пользователя в сайдбаре; Bearer к control-api в `client.do`; индикатор — только при включённой аутентификации |
|
||||||
|
| Развёртывание | переменные в `deploy/docker/*`, `rxprod-compose/docker-compose.yml`, `.env*.example`, `RUN.txt`; `EnvironmentFile=` в 4 systemd-юнитах; ключи в `configs/*.example.yaml` и копиях `rxprod-compose/sources/` |
|
||||||
|
| Тесты | `internal/httpapi/auth_test.go` (матрица по таблице маршрутов), `internal/apiclient/apiclient_test.go`, токены в `agentcore`/`probercore`, `internal/dashboard/auth_test.go` (16 тестов); `scripts/run-local-e2e.sh` — токены и явные проверки 401/open |
|
||||||
|
|
||||||
|
Новых зависимостей нет (`crypto/*` стандартной библиотеки). Режим по умолчанию — обратная совместимость: пустой токен ⇒ соответствующий уровень открыт, в логе предупреждение.
|
||||||
|
|
||||||
|
## Результаты проверок
|
||||||
|
|
||||||
|
| Проверка | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `gofmt`, `go build ./...`, `go vet ./...` | чисто |
|
||||||
|
| `go test ./...` | все пакеты зелёные |
|
||||||
|
| `go test -race` (httpapi, dashboard, apiclient, agentcore, probercore) | зелёные |
|
||||||
|
| `scripts/run-local-e2e.sh` с токенами | exit 0: 5 проверок доступа ok, реальные агент и пробер с токеном довели адрес до `pass`, автоцикл ok |
|
||||||
|
| Живые контейнеры `cloud-ip-validator-*`, `curl` | **40/40** (control-api 401/200, открытые маршруты, heartbeat внешних валидаторов продолжается; дашборд: редиректы, `HX-Redirect`, CSRF, cookie-флаги, open-redirect, tampered cookie, выход) |
|
||||||
|
| Живой дашборд в настоящем Chromium (Playwright) | **15/15**: вход/ошибка пароля, фоновый htmx-опрос с сессией, htmx `PUT /settings` и `PUT /settings/auto-cycle` (Origin-проверка проходит), выход, нет JS-ошибок |
|
||||||
|
| Изолированный стенд `civ-authtest-*` (mock, отдельная сеть, удалён после прогона) | токен агентов: `401` без токена / с токеном администратора / с чужим, принят с токеном агентов; реальный агент **без токена** регистрируется и шлёт heartbeat (открытые маршруты), а его записи отклоняются (41 отказ в логе); **с токеном** новых отказов нет, self-check уходит; пробер с токеном регистрируется; перебор: 6-я неверная попытка → `429`, `Retry-After` 600 с, верный пароль в блокировке тоже `429`; секреты в логах не найдены |
|
||||||
|
|
||||||
|
Примечание: 4 «FAIL» в выводе скрипта изолированного стенда — ошибка форматирования самого скрипта (сравнивалось `yes` со строкой `yes (…)`);
|
||||||
|
значения в скобках подтверждают успех (HTTP 200, 41 отказ, 3 IP, `Retry-After` 600). Реальных провалов нет.
|
||||||
|
|
||||||
|
## Замечания ревью
|
||||||
|
|
||||||
|
| № | Серьёзность | Замечание | Статус |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | средняя | **Страница входа: сломана вёрстка** — `.field` имеет `flex: 1 1 220px`, в колонке это давало 220px пустоты между полями; поле пароля не попадало в правило стилей `input[type=…]` и выглядело нестилизованным. Найдено по скриншоту на живом стенде | **исправлено** (`dashboard.css`: `input[type="password"]` в общее правило, `.login-card .field { flex: 0 0 auto }`), перепроверено скриншотом |
|
||||||
|
| 2 | средняя | Счётчик перебора ключуется по `RemoteAddr` и **не доверяет** `X-Forwarded-For`. За reverse-proxy все клиенты разделят одну корзину: 5 неверных попыток заблокируют вход всем (DoS на админа). Сейчас не проявляется — дашборд открыт напрямую на `:8091`, Caddy перед ним нет | принято; при публикации через прокси нужен список доверенных прокси |
|
||||||
|
| 3 | низкая | Сессия без состояния: «Выйти» стирает cookie в браузере, но украденная копия остаётся валидной до истечения (`session_ttl_minutes`); смена пароля сессии не отзывает. Инвалидация всех сессий — смена `ADMIN_DASHBOARD_SESSION_SECRET` и рестарт | принято, описано в документации |
|
||||||
|
| 4 | низкая | На защищённых страницах нет `Cache-Control: no-store` (кнопка «Назад» после выхода может показать кэш) | не исправлено |
|
||||||
|
| 5 | низкая | `register` и `heartbeat` открыты (решение пользователя): подделка heartbeat «оживляет» упавший валидатор. Перенос под токен агентов — одна строка в таблице маршрутов | принято по решению пользователя |
|
||||||
|
| 6 | низкая | Токены и пароль передаются по HTTP открытым текстом, если TLS не терминируется перед сервисами | описано в документации (TLS на reverse-proxy) |
|
||||||
|
| 7 | инфо | `withAuthInfo` заполняет `PageData` через `reflect` — работает, но хрупко при смене структур | не блокирует |
|
||||||
|
| 8 | инфо | Не добавлен вариант `TestEndToEndHTTPFlow` с токенами — покрыто матрицей по таблице маршрутов и e2e-скриптом | принято |
|
||||||
|
| 9 | инфо | CSRF-проверка сверяет `Origin` с `Host`: прокси, подменяющий `Host`, получит `403` на POST/PUT | описано в документации |
|
||||||
|
|
||||||
|
## Состояние живого стенда (`rxprod-compose`, проект `cloud-ip-validator`)
|
||||||
|
|
||||||
|
- **Включено:** токен администратора на control-api (`CONTROL_API_ADMIN_TOKEN`), тот же токен у дашборда (`ADMIN_DASHBOARD_CONTROL_API_TOKEN`),
|
||||||
|
вход в дашборд (`ADMIN_DASHBOARD_USERNAME` / `ADMIN_DASHBOARD_PASSWORD`), ключ сессии (`ADMIN_DASHBOARD_SESSION_SECRET`).
|
||||||
|
Значения — в `rxprod-compose/.env` (gitignored; права ужесточены до `600`). Дашборд: `http://<хост>:8091/`, логин `admin`.
|
||||||
|
- **Не включено намеренно:** `CONTROL_API_AGENT_TOKEN`. На стенде работают внешние компоненты со старыми бинарниками — валидаторы
|
||||||
|
`validator-1`/`validator-2` (облачные ВМ) и внешний пробер `rxyc`: после включения токена агентов их результаты начнут получать `401`.
|
||||||
|
Пока записывающие вызовы агентов открыты (в логе control-api — предупреждение `agent write API is open`).
|
||||||
|
- **Раскатка токена агентов:** (1) обновить бинарники на валидаторах и внешних проберах; (2) задать им `CONTROL_API_AGENT_TOKEN`
|
||||||
|
(`openssl rand -hex 32`) и перезапустить; (3) последним добавить тот же токен в `.env` стенда (control-api и prober) и выполнить `docker compose up -d`.
|
||||||
|
- Образы `civ-capi`, `civ-adash`, `civ-prober`, `civ-agent` пересобраны; предыдущие сохранены под тегом `:pre-auth`
|
||||||
|
(откат: `docker tag civ-capi:pre-auth civ-capi:latest` и `docker compose up -d`). Бэкап БД и прежнего `.env` — в каталоге scratchpad сессии (`/tmp`, временный).
|
||||||
|
- `bin/` пересобран (`CGO_ENABLED=0`, `-trimpath -ldflags="-s -w"`), `bin/SHA256SUMS` обновлён.
|
||||||
|
|
||||||
|
## Что осталось
|
||||||
|
|
||||||
|
- Раскатка токена агентов на внешние компоненты (см. выше).
|
||||||
|
- По желанию: `Cache-Control: no-store` (замечание 4), список доверенных прокси для счётчика перебора (замечание 2).
|
||||||
@@ -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
|
// Run registers with the Control API and polls forever until ctx is
|
||||||
// cancelled.
|
// cancelled.
|
||||||
func (a *Agent) Run(ctx context.Context) error {
|
func (a *Agent) Run(ctx context.Context) error {
|
||||||
|
|||||||
@@ -98,3 +98,34 @@ func TestRegisterWithRetryStopsOnCancel(t *testing.T) {
|
|||||||
t.Fatalf("expected context.Canceled, got %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ import (
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
|
// Token, when non-empty, is sent as "Authorization: Bearer <Token>" on
|
||||||
|
// every request to the Control API.
|
||||||
|
Token string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(baseURL string, timeout time.Duration) *Client {
|
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 {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.HTTPClient.Do(req)
|
resp, err := c.HTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,15 @@ type ControlAPI struct {
|
|||||||
Targets map[string][]string `yaml:"targets"`
|
Targets map[string][]string `yaml:"targets"`
|
||||||
Inbound InboundConfig `yaml:"inbound_checks"`
|
Inbound InboundConfig `yaml:"inbound_checks"`
|
||||||
IPAddresses []string `yaml:"ip_addresses"`
|
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 {
|
type ServerConfig struct {
|
||||||
@@ -155,6 +164,12 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
|
|||||||
if c.OpenStack.PasswordEnv == "" {
|
if c.OpenStack.PasswordEnv == "" {
|
||||||
c.OpenStack.PasswordEnv = "OS_PASSWORD"
|
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 {
|
if c.Orchestrator.PollIntervalSeconds == 0 {
|
||||||
c.Orchestrator.PollIntervalSeconds = 5
|
c.Orchestrator.PollIntervalSeconds = 5
|
||||||
}
|
}
|
||||||
@@ -182,8 +197,11 @@ func LoadControlAPI(path string) (*ControlAPI, error) {
|
|||||||
// ---- validator-agent ----
|
// ---- validator-agent ----
|
||||||
|
|
||||||
type ValidatorAgent struct {
|
type ValidatorAgent struct {
|
||||||
ValidatorID string `yaml:"validator_id"`
|
ValidatorID string `yaml:"validator_id"`
|
||||||
ControlAPIURL string `yaml:"control_api_url"`
|
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"`
|
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||||
SelfCheck SelfCheckCfg `yaml:"self_check"`
|
SelfCheck SelfCheckCfg `yaml:"self_check"`
|
||||||
Checks AgentChecks `yaml:"checks"`
|
Checks AgentChecks `yaml:"checks"`
|
||||||
@@ -223,6 +241,9 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
|
|||||||
if c.PollIntervalSeconds == 0 {
|
if c.PollIntervalSeconds == 0 {
|
||||||
c.PollIntervalSeconds = 5
|
c.PollIntervalSeconds = 5
|
||||||
}
|
}
|
||||||
|
if c.ControlAPITokenEnv == "" {
|
||||||
|
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
|
||||||
|
}
|
||||||
if c.SelfCheck.TimeoutSeconds == 0 {
|
if c.SelfCheck.TimeoutSeconds == 0 {
|
||||||
c.SelfCheck.TimeoutSeconds = 10
|
c.SelfCheck.TimeoutSeconds = 10
|
||||||
}
|
}
|
||||||
@@ -253,8 +274,11 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
|
|||||||
// ---- prober ----
|
// ---- prober ----
|
||||||
|
|
||||||
type Prober struct {
|
type Prober struct {
|
||||||
SiteID string `yaml:"site_id"`
|
SiteID string `yaml:"site_id"`
|
||||||
ControlAPIURL string `yaml:"control_api_url"`
|
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"`
|
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||||
Checks ProberChecks `yaml:"checks"`
|
Checks ProberChecks `yaml:"checks"`
|
||||||
}
|
}
|
||||||
@@ -273,6 +297,9 @@ func LoadProber(path string) (*Prober, error) {
|
|||||||
if c.PollIntervalSeconds == 0 {
|
if c.PollIntervalSeconds == 0 {
|
||||||
c.PollIntervalSeconds = 5
|
c.PollIntervalSeconds = 5
|
||||||
}
|
}
|
||||||
|
if c.ControlAPITokenEnv == "" {
|
||||||
|
c.ControlAPITokenEnv = "CONTROL_API_AGENT_TOKEN"
|
||||||
|
}
|
||||||
if c.Checks.TCPTimeoutSeconds == 0 {
|
if c.Checks.TCPTimeoutSeconds == 0 {
|
||||||
c.Checks.TCPTimeoutSeconds = 5
|
c.Checks.TCPTimeoutSeconds = 5
|
||||||
}
|
}
|
||||||
@@ -300,11 +327,25 @@ type AdminDashboard struct {
|
|||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server"`
|
||||||
ControlAPI DashboardControlAPIConfig `yaml:"control_api"`
|
ControlAPI DashboardControlAPIConfig `yaml:"control_api"`
|
||||||
Overview DashboardOverviewConfig `yaml:"overview"`
|
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 {
|
type DashboardControlAPIConfig struct {
|
||||||
BaseURL string `yaml:"base_url"`
|
BaseURL string `yaml:"base_url"`
|
||||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
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 "текущая
|
// DashboardOverviewConfig configures the overview page's "текущая
|
||||||
@@ -331,6 +372,21 @@ func LoadAdminDashboard(path string) (*AdminDashboard, error) {
|
|||||||
if c.ControlAPI.TimeoutSeconds == 0 {
|
if c.ControlAPI.TimeoutSeconds == 0 {
|
||||||
c.ControlAPI.TimeoutSeconds = 10
|
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 {
|
if c.Overview.LastCompletedCount == 0 {
|
||||||
c.Overview.LastCompletedCount = 20
|
c.Overview.LastCompletedCount = 20
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ func (e *apiErr) Error() string {
|
|||||||
type client struct {
|
type client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
http *http.Client
|
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 {
|
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 {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ type fakeControlAPI struct {
|
|||||||
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
|
// autoCycleDown makes all four endpoints answer 500 (unavailable API).
|
||||||
autoCycle autoCycleDTO
|
autoCycle autoCycleDTO
|
||||||
autoCycleDown bool
|
autoCycleDown bool
|
||||||
|
|
||||||
|
// authHeaders records the Authorization header of every request served.
|
||||||
|
authMu sync.Mutex
|
||||||
|
authHeaders []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeControlAPI(t *testing.T) (*fakeControlAPI, 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})
|
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 {
|
func (f *fakeControlAPI) findIP(addr string) int {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (s *Server) handleCheckTypesPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
data, err := s.loadCheckTypesPage(r)
|
data, err := s.loadCheckTypesPage(r)
|
||||||
data.ActiveNav = "check-types"
|
data.ActiveNav = "check-types"
|
||||||
data.Banner = bannerFor(err)
|
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) {
|
func (s *Server) renderCheckTypesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (s *Server) handleIPsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}
|
data := ipsPageData{Items: items, FIPSettleSeconds: settings.FIPSettleSeconds}
|
||||||
data.ActiveNav = "ips"
|
data.ActiveNav = "ips"
|
||||||
data.Banner = bannerFor(err)
|
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) {
|
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 := ipDetailData{Detail: detail}
|
||||||
data.ActiveNav = "ips"
|
data.ActiveNav = "ips"
|
||||||
data.Banner = bannerFor(err)
|
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
|
// renderIPsTable re-fetches the current queue and renders the ips_table
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
data, err := s.loadOverview(r)
|
data, err := s.loadOverview(r)
|
||||||
data.ActiveNav = "overview"
|
data.ActiveNav = "overview"
|
||||||
data.Banner = bannerFor(err)
|
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
|
// handleOverviewFragment serves both the recurring poll and every
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (s *Server) handleRegistryPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
data.ActiveNav = "registry"
|
data.ActiveNav = "registry"
|
||||||
data.Banner = bannerFor(err)
|
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
|
// 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 := registryDetailData{History: history}
|
||||||
data.ActiveNav = "registry"
|
data.ActiveNav = "registry"
|
||||||
data.Banner = bannerFor(err)
|
data.Banner = bannerFor(err)
|
||||||
s.renderPage(w, "registry_detail_page", data)
|
s.renderPage(w, r, "registry_detail_page", data)
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
|
data := settingsPageData{Settings: settings, Inbound: inbound, AutoCycle: autoCycle}
|
||||||
data.ActiveNav = "settings"
|
data.ActiveNav = "settings"
|
||||||
data.Banner = bannerFor(err)
|
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
|
// renderSettingsForm re-fetches the current settings and renders the
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func (s *Server) handleSitesPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := sitesPageData{Items: items}
|
data := sitesPageData{Items: items}
|
||||||
data.ActiveNav = "sites"
|
data.ActiveNav = "sites"
|
||||||
data.Banner = bannerFor(err)
|
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) {
|
func (s *Server) renderSitesTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
data, err := s.loadTargetsPage(r)
|
data, err := s.loadTargetsPage(r)
|
||||||
data.ActiveNav = "targets"
|
data.ActiveNav = "targets"
|
||||||
data.Banner = bannerFor(err)
|
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,
|
// renderTargetsTable renders the #targets-table-wrap swap target plus,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (s *Server) handleValidatorsPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := validatorsPageData{Items: items}
|
data := validatorsPageData{Items: items}
|
||||||
data.ActiveNav = "validators"
|
data.ActiveNav = "validators"
|
||||||
data.Banner = bannerFor(err)
|
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) {
|
func (s *Server) renderValidatorsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ type bannerData struct {
|
|||||||
type PageData struct {
|
type PageData struct {
|
||||||
Banner bannerData
|
Banner bannerData
|
||||||
ActiveNav string
|
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 {
|
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
|
// navigation. actionErr (if any — e.g. the primary control-api call for
|
||||||
// this page failed) is surfaced via the embedded PageData.Banner, which
|
// this page failed) is surfaced via the embedded PageData.Banner, which
|
||||||
// the caller must have already set via bannerFor.
|
// 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")
|
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 {
|
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
s.Log.Error("render page", "template", name, "err", err)
|
s.Log.Error("render page", "template", name, "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ package dashboard
|
|||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
func (s *Server) routes(mux *http.ServeMux) {
|
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 /{$}", s.handleIndex)
|
||||||
|
|
||||||
mux.HandleFunc("GET /overview", s.handleOverview)
|
mux.HandleFunc("GET /overview", s.handleOverview)
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ type Config struct {
|
|||||||
ControlAPITimeout time.Duration
|
ControlAPITimeout time.Duration
|
||||||
LastCompletedCount int
|
LastCompletedCount int
|
||||||
OverviewPollIntervalS 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 {
|
type Server struct {
|
||||||
@@ -25,6 +38,7 @@ type Server struct {
|
|||||||
Cfg Config
|
Cfg Config
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
auth *authState
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config, log *slog.Logger) (*Server, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
ca := newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout)
|
||||||
|
ca.token = cfg.ControlAPIToken
|
||||||
return &Server{
|
return &Server{
|
||||||
CA: newClient(cfg.ControlAPIBaseURL, cfg.ControlAPITimeout),
|
CA: ca,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
Log: log,
|
Log: log,
|
||||||
|
auth: newAuthState(cfg, log),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s.routes(mux)
|
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 {
|
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -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; }
|
.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-family: var(--font-ui);
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
padding: 7px 10px;
|
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
|
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. */
|
field's own line-height, so it stays correct if font-size ever changes. */
|
||||||
textarea.autosize { overflow-y: hidden; resize: none; }
|
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; }
|
||||||
@@ -78,6 +78,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{{if .AuthEnabled}}
|
||||||
|
<form class="sidebar-user" method="post" action="/logout">
|
||||||
|
<span class="sidebar-user-name" title="{{.User}}">{{.User}}</span>
|
||||||
|
<button type="submit" class="btn btn-ghost btn-sm">Выйти</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="sidebar-foot">
|
<div class="sidebar-foot">
|
||||||
<span class="sidebar-foot-status"><span class="pulse-dot"></span>control-api</span>
|
<span class="sidebar-foot-status"><span class="pulse-dot"></span>control-api</span>
|
||||||
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
|
<button type="button" class="theme-toggle" id="themeToggle" aria-label="Переключить тему" title="Переключить тему">
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{define "login_page"}}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>{{template "html_head" .}}</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-grid"></div>
|
||||||
|
<main class="login-shell">
|
||||||
|
<div class="login-card">
|
||||||
|
<form class="panel" method="post" action="/login" autocomplete="on">
|
||||||
|
<div class="panel-head"><h2>Cloud IP Validator — вход</h2></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{{if .Error}}<div class="alert alert-warning" role="alert">{{.Error}}</div>{{end}}
|
||||||
|
<input type="hidden" name="next" value="{{.Next}}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-username">Логин</label>
|
||||||
|
<input type="text" id="login-username" name="username" autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="login-password">Пароль</label>
|
||||||
|
<input type="password" id="login-password" name="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Войти</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// accessLevel says which credential a route requires.
|
||||||
|
type accessLevel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// accessOpen: no credential required (registration, heartbeat, fetching
|
||||||
|
// the assignment, /healthz).
|
||||||
|
accessOpen accessLevel = iota + 1
|
||||||
|
// accessAgent: requires the static agent token (validator-agents and
|
||||||
|
// probers writing results/events).
|
||||||
|
accessAgent
|
||||||
|
// accessAdmin: requires the administrator token (/api/v1/admin/*).
|
||||||
|
accessAdmin
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a accessLevel) String() string {
|
||||||
|
switch a {
|
||||||
|
case accessOpen:
|
||||||
|
return "open"
|
||||||
|
case accessAgent:
|
||||||
|
return "agent"
|
||||||
|
case accessAdmin:
|
||||||
|
return "admin"
|
||||||
|
}
|
||||||
|
return "invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticator holds the static bearer tokens. An empty token disables the
|
||||||
|
// check for that level (backward compatibility: the API starts open with a
|
||||||
|
// warning, see cmd/control-api).
|
||||||
|
type Authenticator struct {
|
||||||
|
AdminToken string
|
||||||
|
AgentToken string
|
||||||
|
Log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAuth sets the admin and agent tokens. Call it before Handler().
|
||||||
|
func (s *Server) WithAuth(admin, agent string) *Server {
|
||||||
|
s.Auth = Authenticator{AdminToken: admin, AgentToken: agent}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Authenticator) tokenFor(level accessLevel) string {
|
||||||
|
switch level {
|
||||||
|
case accessAdmin:
|
||||||
|
return a.AdminToken
|
||||||
|
case accessAgent:
|
||||||
|
return a.AgentToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// require wraps h so that it is only reached with the credential demanded by
|
||||||
|
// level. Open routes, and levels with an empty configured token, pass through.
|
||||||
|
func (a Authenticator) require(level accessLevel, h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
if level == accessOpen {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if level != accessAdmin && level != accessAgent {
|
||||||
|
// An unclassified route must never be served.
|
||||||
|
panic("httpapi: route registered without a valid access level")
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
want := a.tokenFor(level)
|
||||||
|
if want == "" {
|
||||||
|
h(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got, ok := bearerToken(r)
|
||||||
|
if !ok || !tokensEqual(got, want) {
|
||||||
|
if a.Log != nil {
|
||||||
|
a.Log.Warn("unauthorized request", "level", level.String(),
|
||||||
|
"method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||||
|
}
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerToken extracts the token from "Authorization: Bearer <token>".
|
||||||
|
func bearerToken(r *http.Request) (string, bool) {
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
const prefix = "bearer "
|
||||||
|
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
tok := strings.TrimSpace(h[len(prefix):])
|
||||||
|
return tok, tok != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokensEqual compares in constant time; hashing first hides the length.
|
||||||
|
func tokensEqual(got, want string) bool {
|
||||||
|
g := sha256.Sum256([]byte(got))
|
||||||
|
w := sha256.Sum256([]byte(want))
|
||||||
|
return subtle.ConstantTimeCompare(g[:], w[:]) == 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cloudipvalidator/internal/config"
|
||||||
|
"cloudipvalidator/internal/db"
|
||||||
|
"cloudipvalidator/internal/openstack"
|
||||||
|
"cloudipvalidator/internal/orchestrator"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testAdminToken = "admin-token-for-tests"
|
||||||
|
testAgentToken = "agent-token-for-tests"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAuthTestServer(t *testing.T, admin, agent string) (*Server, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
d, err := db.Open(ctx, filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { d.Close() })
|
||||||
|
cfg := &config.ControlAPI{
|
||||||
|
Orchestrator: config.OrchestratorConfig{
|
||||||
|
PollIntervalSeconds: 1, SelfCheckTimeoutSeconds: 10, MaxSelfCheckRetries: 3,
|
||||||
|
CheckingWindowSeconds: 120, MaxRetries: 3, LeaseTTLSeconds: 180, HeartbeatTimeoutSeconds: 30,
|
||||||
|
},
|
||||||
|
Inbound: config.InboundConfig{Ports: []int{22}, ICMP: true},
|
||||||
|
}
|
||||||
|
if err := d.BootstrapFromConfig(ctx, cfg); err != nil {
|
||||||
|
t.Fatalf("bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
srv := New(d, orchestrator.New(d, openstack.NewMockClient(), cfg, log), log).WithAuth(admin, agent)
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return srv, ts
|
||||||
|
}
|
||||||
|
|
||||||
|
// concretePath fills the {wildcards} of a ServeMux pattern with dummy values.
|
||||||
|
func concretePath(pattern string) (method, path string) {
|
||||||
|
method, path, _ = strings.Cut(pattern, " ")
|
||||||
|
var b strings.Builder
|
||||||
|
for {
|
||||||
|
i := strings.IndexByte(path, '{')
|
||||||
|
if i < 0 {
|
||||||
|
b.WriteString(path)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
j := strings.IndexByte(path, '}')
|
||||||
|
b.WriteString(path[:i])
|
||||||
|
b.WriteString("1")
|
||||||
|
path = path[j+1:]
|
||||||
|
}
|
||||||
|
return method, b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func callWithToken(t *testing.T, ts *httptest.Server, pattern, token string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
method, path := concretePath(pattern)
|
||||||
|
req, err := http.NewRequest(method, ts.URL+path, strings.NewReader("{}"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", pattern, err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteTableIsClassified(t *testing.T) {
|
||||||
|
srv, _ := newAuthTestServer(t, "", "")
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, rt := range srv.Routes() {
|
||||||
|
counts[rt.Access]++
|
||||||
|
if rt.Access == "invalid" {
|
||||||
|
t.Fatalf("route %q has no valid access level", rt.Pattern)
|
||||||
|
}
|
||||||
|
if strings.Contains(rt.Pattern, "/api/v1/admin/") && rt.Access != "admin" {
|
||||||
|
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if counts["admin"] != 33 || counts["agent"] != 5 || counts["open"] != 7 {
|
||||||
|
t.Fatalf("access counts = %v, want admin=33 agent=5 open=7", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMatrixOverRouteTable(t *testing.T) {
|
||||||
|
srv, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
|
||||||
|
for _, rt := range srv.Routes() {
|
||||||
|
rt := rt
|
||||||
|
t.Run(rt.Pattern, func(t *testing.T) {
|
||||||
|
tokens := []struct {
|
||||||
|
name, token string
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
{"none", "", rt.Access == "open"},
|
||||||
|
{"wrong", "definitely-wrong", rt.Access == "open"},
|
||||||
|
{"admin token", testAdminToken, rt.Access == "open" || rt.Access == "admin"},
|
||||||
|
{"agent token", testAgentToken, rt.Access == "open" || rt.Access == "agent"},
|
||||||
|
}
|
||||||
|
for _, tc := range tokens {
|
||||||
|
resp := callWithToken(t, ts, rt.Pattern, tc.token)
|
||||||
|
if tc.allowed && resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
t.Fatalf("%s: got 401, want request to pass auth", tc.name)
|
||||||
|
}
|
||||||
|
if !tc.allowed {
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("%s: status=%d, want 401", tc.name, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.Header.Get("WWW-Authenticate") != "Bearer" {
|
||||||
|
t.Fatalf("%s: missing WWW-Authenticate: Bearer", tc.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyTokensLeaveEverythingOpen(t *testing.T) {
|
||||||
|
srv, ts := newAuthTestServer(t, "", "")
|
||||||
|
for _, rt := range srv.Routes() {
|
||||||
|
if resp := callWithToken(t, ts, rt.Pattern, ""); resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
t.Fatalf("%s: got 401 with no tokens configured", rt.Pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyConfiguredLevelIsEnforced(t *testing.T) {
|
||||||
|
_, ts := newAuthTestServer(t, testAdminToken, "")
|
||||||
|
if resp := callWithToken(t, ts, "GET /api/v1/admin/status", ""); resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("admin without token: status=%d, want 401", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp := callWithToken(t, ts, "POST /api/v1/agents/{id}/results", ""); resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
t.Fatalf("agent route must stay open while agent token is unset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthzAlwaysOpenAndBearerParsing(t *testing.T) {
|
||||||
|
_, ts := newAuthTestServer(t, testAdminToken, testAgentToken)
|
||||||
|
if resp := callWithToken(t, ts, "GET /healthz", ""); resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("healthz: status=%d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Raw token without the Bearer scheme must not authenticate.
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
|
||||||
|
req.Header.Set("Authorization", testAdminToken)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("scheme-less token: status=%d, want 401", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Lowercase scheme is accepted (RFC 7235: case-insensitive).
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil)
|
||||||
|
req.Header.Set("Authorization", "bearer "+testAdminToken)
|
||||||
|
resp, err = ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("lowercase bearer: status=%d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
-53
@@ -2,61 +2,83 @@ package httpapi
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
func (s *Server) routes(mux *http.ServeMux) {
|
// route is one entry of the Control API route table. The access level is
|
||||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
// 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)
|
// RouteInfo is the exported, handler-free view of a route table entry.
|
||||||
mux.HandleFunc("POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat)
|
type RouteInfo struct {
|
||||||
mux.HandleFunc("GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment)
|
Pattern string
|
||||||
mux.HandleFunc("POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck)
|
Access string // "open", "agent" or "admin"
|
||||||
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)
|
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister)
|
// Routes returns the access classification of every registered route.
|
||||||
mux.HandleFunc("POST /api/v1/probers/{site_id}/heartbeat", s.handleProberHeartbeat)
|
func (s *Server) Routes() []RouteInfo {
|
||||||
mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments)
|
table := s.routeTable()
|
||||||
mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults)
|
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)
|
func (s *Server) routes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/v1/admin/ips", s.handleAdminIPs)
|
for _, rt := range s.routeTable() {
|
||||||
mux.HandleFunc("POST /api/v1/admin/ips", s.handleAdminSubmitIPs)
|
mux.HandleFunc(rt.Pattern, s.Auth.require(rt.Access, rt.Handler))
|
||||||
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)
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/v1/admin/config/inbound-checks", s.handleConfigGetInboundChecks)
|
func (s *Server) routeTable() []route {
|
||||||
mux.HandleFunc("PUT /api/v1/admin/config/inbound-checks", s.handleConfigPutInboundChecks)
|
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},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,7 @@ type Server struct {
|
|||||||
DB *db.DB
|
DB *db.DB
|
||||||
Orch *orchestrator.Orchestrator
|
Orch *orchestrator.Orchestrator
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
Auth Authenticator
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
|
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 {
|
func (s *Server) Handler() http.Handler {
|
||||||
|
s.Auth.Log = s.Log
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s.routes(mux)
|
s.routes(mux)
|
||||||
return loggingMiddleware(s.Log, mux)
|
return loggingMiddleware(s.Log, mux)
|
||||||
|
|||||||
@@ -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 {
|
func (p *Prober) Run(ctx context.Context) error {
|
||||||
if err := p.registerWithRetry(ctx); err != nil {
|
if err := p.registerWithRetry(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ services:
|
|||||||
OS_USERNAME: "${OS_USERNAME:-}"
|
OS_USERNAME: "${OS_USERNAME:-}"
|
||||||
OS_USER_DOMAIN_NAME: "${OS_USER_DOMAIN_NAME:-}"
|
OS_USER_DOMAIN_NAME: "${OS_USER_DOMAIN_NAME:-}"
|
||||||
OS_PASSWORD: "${OS_PASSWORD:-}"
|
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:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8081/healthz"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:8081/healthz"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -44,6 +48,12 @@ services:
|
|||||||
- "8091:8090"
|
- "8091:8090"
|
||||||
environment:
|
environment:
|
||||||
ADMIN_DASHBOARD_CONTROL_API_URL: http://control-api:8081
|
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:
|
depends_on:
|
||||||
control-api:
|
control-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -57,6 +67,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PROBER_SITE_ID: "${PROBER_SITE_ID}"
|
PROBER_SITE_ID: "${PROBER_SITE_ID}"
|
||||||
PROBER_CONTROL_API_URL: http://control-api:8081
|
PROBER_CONTROL_API_URL: http://control-api:8081
|
||||||
|
CONTROL_API_AGENT_TOKEN: "${CONTROL_API_AGENT_TOKEN:-}"
|
||||||
depends_on:
|
depends_on:
|
||||||
control-api:
|
control-api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -4,6 +4,23 @@ server:
|
|||||||
control_api:
|
control_api:
|
||||||
base_url: "http://control-api.internal:8080"
|
base_url: "http://control-api.internal:8080"
|
||||||
timeout_seconds: 10
|
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. Оба поля
|
# Настройки сводки на странице "Обзор" — см. docs/DASHBOARD.md. Оба поля
|
||||||
# влияют только на то, как дашборд группирует уже существующие данные
|
# влияют только на то, как дашборд группирует уже существующие данные
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ openstack:
|
|||||||
user_domain_name_env: "OS_USER_DOMAIN_NAME"
|
user_domain_name_env: "OS_USER_DOMAIN_NAME"
|
||||||
password_env: "OS_PASSWORD"
|
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:
|
orchestrator:
|
||||||
poll_interval_seconds: 5
|
poll_interval_seconds: 5
|
||||||
self_check_timeout_seconds: 60
|
self_check_timeout_seconds: 60
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
|
|
||||||
site_id: "site-1"
|
site_id: "site-1"
|
||||||
control_api_url: "http://control-api.internal:8080"
|
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
|
poll_interval_seconds: 5
|
||||||
|
|
||||||
checks:
|
checks:
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
|
|
||||||
validator_id: "validator_01"
|
validator_id: "validator_01"
|
||||||
control_api_url: "http://control-api.internal:8080"
|
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
|
poll_interval_seconds: 5
|
||||||
|
|
||||||
self_check:
|
self_check:
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ BIN_DIR="$WORK_DIR/bin"
|
|||||||
LOG_DIR="$WORK_DIR/logs"
|
LOG_DIR="$WORK_DIR/logs"
|
||||||
mkdir -p "$BIN_DIR" "$LOG_DIR"
|
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=()
|
PIDS=()
|
||||||
cleanup() {
|
cleanup() {
|
||||||
echo "--- cleaning up (workdir: $WORK_DIR) ---"
|
echo "--- cleaning up (workdir: $WORK_DIR) ---"
|
||||||
@@ -148,6 +156,31 @@ for i in $(seq 1 30); do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo "--- authentication checks ---"
|
||||||
|
BASE="http://127.0.0.1:28080"
|
||||||
|
http_code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
|
||||||
|
expect_code() { # expect_code <want> <description> <curl args...>
|
||||||
|
local want="$1" desc="$2" got
|
||||||
|
shift 2
|
||||||
|
got="$(http_code "$@")"
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
echo "FAIL: $desc: expected HTTP $want, got $got"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ok: $desc -> $got"
|
||||||
|
}
|
||||||
|
expect_code 401 "admin endpoint without token" "$BASE/api/v1/admin/status"
|
||||||
|
expect_code 200 "admin endpoint with admin token" "${ADMIN_AUTH[@]}" "$BASE/api/v1/admin/status"
|
||||||
|
expect_code 401 "agent results without token" -X POST -H 'Content-Type: application/json' -d '{}' "$BASE/api/v1/agents/validator_01/results"
|
||||||
|
expect_code 401 "agent results with the ADMIN token" -X POST "${ADMIN_AUTH[@]}" -H 'Content-Type: application/json' -d '{}' "$BASE/api/v1/agents/validator_01/results"
|
||||||
|
# Open route: whatever the status (200/204/404 depending on state), it must not be 401.
|
||||||
|
assignment_code="$(http_code "$BASE/api/v1/agents/validator_01/assignment")"
|
||||||
|
if [ "$assignment_code" = "401" ]; then
|
||||||
|
echo "FAIL: GET /agents/validator_01/assignment must be open (no token), got 401"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ok: assignment without token is not rejected -> $assignment_code"
|
||||||
|
|
||||||
start_validator_agent() {
|
start_validator_agent() {
|
||||||
"$BIN_DIR/validator-agent" -config "$WORK_DIR/validator-agent.yaml" -stub-ports "22022,28081,28443,28888" \
|
"$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=$!
|
>>"$LOG_DIR/validator-agent.log" 2>&1 & VALIDATOR_PID=$!
|
||||||
@@ -155,8 +188,8 @@ start_validator_agent() {
|
|||||||
echo "validator-agent started (pid $VALIDATOR_PID)"
|
echo "validator-agent started (pid $VALIDATOR_PID)"
|
||||||
}
|
}
|
||||||
|
|
||||||
status() { curl -fs "http://127.0.0.1:28080/api/v1/admin/status"; }
|
status() { curl -fs "${ADMIN_AUTH[@]}" "http://127.0.0.1:28080/api/v1/admin/status"; }
|
||||||
ips() { curl -fs "http://127.0.0.1:28080/api/v1/admin/ips"; }
|
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 "?"; }
|
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 ---"
|
echo "--- starting validator-agent ---"
|
||||||
@@ -211,7 +244,7 @@ echo "--- final ip results ---"
|
|||||||
ips | python3 -m json.tool
|
ips | python3 -m json.tool
|
||||||
|
|
||||||
echo "--- demonstrating admin API: force a re-check of the already-done address ---"
|
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
|
-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) ---"
|
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) ---"
|
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"
|
AC_URL="http://127.0.0.1:28080/api/v1/admin/auto-cycle"
|
||||||
registry_cycles() {
|
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
|
import json,sys
|
||||||
for r in json.load(sys.stdin):
|
for r in json.load(sys.stdin):
|
||||||
if r['ip_address'] == '127.0.0.1':
|
if r['ip_address'] == '127.0.0.1':
|
||||||
@@ -238,14 +271,14 @@ for r in json.load(sys.stdin):
|
|||||||
else:
|
else:
|
||||||
print(0)"
|
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)"
|
CYCLES_BEFORE="$(registry_cycles)"
|
||||||
# 60s is the smallest interval control-api accepts; the second cycle is
|
# 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.
|
# 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
|
-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) ---"
|
echo "--- waiting for the first auto cycle to complete (up to 90s) ---"
|
||||||
AC_OUTCOME=""
|
AC_OUTCOME=""
|
||||||
@@ -259,7 +292,7 @@ for i in $(seq 1 180); do
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "--- auto-cycle status ---"
|
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)"
|
CYCLES_AFTER="$(registry_cycles)"
|
||||||
echo "registry total_cycles for 127.0.0.1: $CYCLES_BEFORE -> $CYCLES_AFTER"
|
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
|
fi
|
||||||
|
|
||||||
echo "--- automatic cycle: disable it; no further cycles must start ---"
|
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
|
if [ "$(ac_field enabled)" != "False" ] || [ "$(ac_field phase)" != "idle" ]; then
|
||||||
echo "FAIL: expected enabled=false and phase=idle after stop"
|
echo "FAIL: expected enabled=false and phase=idle after stop"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in new issue
Block a user