Add self-check via control-api (self_check.methods)

control-api is hosted outside the cloud and validators reach it directly,
so it sees the floating IP as the connection's source address. New open
route GET /api/v1/agents/{id}/observed-ip returns that address (taken only
from the TCP peer; forwarding headers are ignored so a validator cannot
forge it).

The agent gets self_check.methods, a priority-ordered list of ip_echo
(unchanged) and control_api; the default stays [ip_echo]. The self-check
passes when any method confirms the address; the next method is tried on
no answer and on a mismatch. Each method has its own timeout so a hung
first method cannot starve the fallback, and control_api uses a new TCP
connection per call (a connection opened before the floating IP was
attached would keep reporting the old address).

Also: docker agent template/env, example config, docs, plan in
docs/changes, e2e script switch E2E_SELF_CHECK_METHODS, rebuilt
bin/control-api and bin/validator-agent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5.5 committed 2026-10-02 03:24:20 +03:00
1 parent 146259cabb
commit abbee9a08a
23 files changed
+765 -35

No files matched your search

@@ -0,0 +1,110 @@
# План: самопроверка через control-api (дополнительный способ сверки публичного IP)
> Дата: 2026-10-02 03:06 · Статус: **реализовано и проверено** (юнит-тесты, локальный e2e с `ip_echo` и с `control_api`) (решения пользователя — в конце)
## Зачем
Самопроверка агента подтверждает, что исходящий трафик валидатора идёт через выданный Floating IP: агент спрашивает свой
публичный адрес у внешнего IP-echo сервиса и сверяет с назначенным адресом. Сейчас это единственный способ, и он хрупкий:
1 октября таймауты `https://ifconfig.me/ip` дали волну провалов self-check (33 случая за вечер).
`control-api` в текущем развёртывании стоит **во внешнем окружении** (вне облака), валидаторы подключаются к нему **напрямую**.
Значит, соединение валидатора с ним выходит наружу через Floating IP, и `control-api` сам видит публичный адрес источника.
Это даёт второй способ сверки без сторонних сервисов: агент спрашивает у `control-api`, с какого адреса тот его видит.
Требование: **существующий способ (IP-echo) сохраняется**, новый добавляется как опция агента.
## Решение в двух строках
1. `control-api` получает ручку «с какого адреса ты меня видишь».
2. Агент получает настройку `self_check.methods` — список способов в порядке приоритета; по умолчанию `[ip_echo]` (всё как сейчас).
## Конфигурация агента
```yaml
self_check:
timeout_seconds: 10
methods: [control_api, ip_echo] # по умолчанию [ip_echo]
ip_echo_urls: [...] # без изменений
```
- Допустимые значения: `ip_echo` (текущий способ), `control_api` (новый). Неизвестное значение — ошибка при старте агента.
- **Самопроверка успешна, если её подтвердил любой из способов.** Способы пробуются по порядку приоритета (первый —
главный); остановка на первом успешном. К следующему способу переходим и при отсутствии ответа (ошибка, таймаут,
404/5xx), и при несовпадении адреса. Провал — только если не подтвердил ни один способ; в `detail` попадает причина по
каждому способу.
- Внутри способа `ip_echo` поведение прежнее: URL перебираются по порядку, переход к следующему URL только при ошибке.
- Пустой список или отсутствие ключа → `[ip_echo]`. Агент без новой настройки ведёт себя ровно как раньше.
- Для внешнего размещения `control-api` в примерах и рекомендациях стоит `[control_api, ip_echo]`: способ через API в приоритете.
- Итог в `detail`: `detected_egress_ip=<ip> matched (control_api)`; видно в событиях адреса и в дашборде.
## Control API
**Новая ручка:** `GET /api/v1/agents/{id}/observed-ip` → `200 {"ip":"90.156.213.5","source":"remote_addr"}`.
- Уровень доступа — **открыто**, как `heartbeat` и `assignment`: ручка отдаёт только адрес самого вызывающего, секретов нет.
- `{id}` должен быть известным валидатором, иначе `404` (чтобы ручка не превращалась в публичный «узнай свой IP»).
- Адрес берётся только из `r.RemoteAddr`, приводится к каноничному виду (`::ffff:1.2.3.4` → `1.2.3.4`).
- Заголовки `X-Forwarded-For`/`X-Real-IP` **не учитываются**: подключение прямое, а доверие к заголовку позволило бы
валидатору подделать адрес и пройти проверку. Если появится обратный прокси, понадобится отдельная настройка
доверенных прокси — сейчас она не нужна (решение 1).
## Агент (`internal/agentcore`)
- Новая функция `detectViaControlAPI`: `GET /api/v1/agents/{id}/observed-ip` на `control_api_url`.
- **Новое TCP-соединение на каждый вызов** (отдельный `http.Transport` с `DisableKeepAlives`). Это ключевой момент:
соединение, открытое до привязки Floating IP (heartbeat, assignment), остаётся в старом NAT-состоянии и покажет
прежний адрес; общий клиент `apiclient` использовать нельзя.
- Токен агентов в этот запрос не нужен (ручка открытая) и не отправляется.
- Таймаут `self_check.timeout_seconds` (сейчас 10 с) действует **на каждый способ отдельно**: при общем дедлайне зависший
первый способ (приоритетный `control_api`) съел бы всё время, и запасной не успел бы ответить. Общий предел — таймаут × число способов.
- `detectPublicIP` заменяется перебором `methods` по приоритету; `fetchIPEcho` не меняется.
- Диагностика: если `control-api` вернул **частный** адрес (RFC 1918 и т. п.), в `detail` пишется подсказка: «control-api
доступен по внутренней сети, самопроверка через него невозможна; используйте ip_echo».
## Ограничение способа (важно для документации)
Способ `control_api` корректен только если соединение валидатора с `control-api` **выходит через внешнюю сеть**
(SNAT Floating IP). Если `control-api` достижим из облака по внутренней сети, он увидит частный адрес валидатора, и
этот способ всегда будет давать несовпадение (при `[control_api, ip_echo]` проверка пройдёт по `ip_echo`).
Если порт `control-api` опубликован через Docker, при выкладке проверить, что ручка показывает внешний адрес клиента, а
не адрес шлюза Docker.
## Откат и совместимость
- Новый агент + старый `control-api`: ручки нет (`404`), при `methods: [control_api, ip_echo]` агент переходит на `ip_echo`.
- Старый агент + новый `control-api`: ничего не меняется, ручка просто не вызывается.
- Откат: убрать `methods` из конфига агента (или вернуть `[ip_echo]`) и перезапустить агент.
- Схема БД и протокол `self-check` (`POST /agents/{id}/self-check`) не меняются.
## Затрагиваемые файлы
| Файл | Изменение |
|---|---|
| `internal/config/config.go` | `SelfCheckCfg.Methods`, дефолт `[ip_echo]` и проверка значений |
| `internal/httpapi/routes.go`, `handlers_agent.go` | маршрут и обработчик `observed-ip` |
| `internal/agentcore/agentcore.go` | `detectViaControlAPI`, перебор `methods` по приоритету |
| `configs/validator-agent.example.yaml` | пример и комментарии |
| `docs/API.md`, `docs/SETUP.md`, `docs/USAGE.md`, `README.md` | описание ручки, опции, ограничения |
| `bin/validator-agent`, `bin/control-api`, `SHA256SUMS` | пересборка |
## Тесты
- **config:** дефолт `[ip_echo]`; допустимые значения; ошибка на неизвестном способе.
- **httpapi:** прямой адрес; IPv4-mapped IPv6; заголовок `X-Forwarded-For` игнорируется; неизвестный валидатор → `404`.
- **agentcore:** порядок способов; успех второго способа после ошибки или несовпадения первого; провал, когда не
подтвердил ни один; каждый вызов открывает новое соединение (тестовый сервер считает соединения); подсказка про частный адрес.
- **e2e:** `scripts/run-local-e2e.sh` остаётся на `ip_echo` (проверка обратной совместимости).
## Выкладка
1. `control-api` с новой ручкой (поведение не меняется): пересборка образа, перезапуск.
2. Агенты на ВМ-валидаторах: новый `bin/validator-agent` и `methods: [control_api, ip_echo]` в их конфиге. Один валидатор
для начала, проверить `detail` в событиях (`matched (control_api)`), затем остальные.
## Решения пользователя (2026-10-02)
1. Подключение валидаторов к `control-api` — **напрямую** (без обратного прокси): `trusted_proxies` не нужен.
2. Ручка `observed-ip` — **открытая**.
3. Достаточно **одной успешной самопроверки любым из способов**; при внешнем размещении API способ через ручку API —
**в приоритете** (первый в списке).