fix public ip selfcheck logic

This commit is contained in:
ayurishchev committed 2026-08-21 11:04:49 +03:00
1 parent a6f9646da5
commit 67adc6670e
18 files changed
+209 -90

No files matched your search

+3 -3
View File
@@ -1,3 +1,3 @@
9a6e8dd5d9be684cd4b9c26dde273c3ce863fc9bf869b2e5811a22e14e7bdc98 control-api
8793491ae048eb57fb4d901a762e8518fc8253e6cecbf371425cbe9d83c46db9 validator-agent
3e9c8b4878aed09e4e946a1c700f6c52b8c5395da33199197097661a4a758bf3 prober
a7c481f3c8421dc8b3985d7c3eda13a0ad1e5d37c7a58a4aaf37c1b18254ff54 control-api
2e34dce04889a25c564bbd0dd9dfc26499c9e63486f8419ff622ca34f7a9bed8 validator-agent
e6ec2444854f624b11f917b7d3cf9fa32f9ee49aff0da637fe2e59140e02133c prober
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+11
View File
@@ -7,6 +7,17 @@ poll_interval_seconds: 5
self_check:
timeout_seconds: 10
# Must be a resource genuinely outside the cloud project — OpenStack only
# applies floating-IP SNAT to traffic leaving via the external network,
# so anything reachable over the project's internal network (including
# control-api itself, if it's on the same internal network) would report
# this validator's private address instead, regardless of whether the
# floating IP is correctly attached. Tried in order; falls through to the
# next URL only on error/timeout, never on a genuine mismatch. Defaults
# to these two public services if omitted.
ip_echo_urls:
- "https://api.ipify.org"
- "https://ifconfig.me/ip"
checks:
https_timeout_seconds: 10
+14 -16
View File
@@ -113,8 +113,15 @@ JSON, базовый префикс прикладных методов — `/ap
Отчёт о результате self-check — подтверждение, что исходящий трафик
валидатора действительно идёт через только что назначенный FIP. Агент
определяет это, вызвав `GET /api/v1/whatsmyip` и сравнив ответ с
`ip_address` из задания.
определяет это **сам**, обращаясь к внешнему (снаружи облака) IP-echo
сервису (`self_check.ip_echo_urls` в `validator-agent.yaml`, например
`api.ipify.org`) и сравнивая ответ с `ip_address` из задания — control-api
в этом определении не участвует. Важно, что ресурс должен быть именно
внешним: OpenStack применяет SNAT через Floating IP только к трафику,
уходящему через внешнюю сеть, поэтому обращение к чему-либо внутри
проекта (в том числе к самому control-api, если он в той же внутренней
сети) покажет приватный адрес валидатора независимо от того, правильно
ли привязан FIP.
Запрос:
```json
@@ -247,17 +254,6 @@ IP на данном проходе". До этого момента control-api
Проверка живости процесса. Ответ: `{"ok": true}`. Используется в systemd/
внешних системах мониторинга.
### `GET /api/v1/whatsmyip`
Возвращает IP-адрес, с которого пришёл TCP-запрос (без учёта заголовков
`X-Forwarded-For` — специально, чтобы self-check нельзя было подделать).
Это основа механизма self-check.
Ответ:
```json
{"ip": "203.0.113.10"}
```
### `GET /api/v1/admin/status`
Сводка по очереди — сколько IP в каком состоянии.
@@ -348,9 +344,11 @@ curl -s -X POST "$BASE/api/v1/agents/register" \
curl -s "$BASE/api/v1/agents/validator_01/assignment"
# => {"ip_id":1,"ip_address":"203.0.113.10","phase":"awaiting_self_check","check_config":[...]}
# 3. Self-check: спросить у control-api, каким адресом мы к нему пришли
curl -s "$BASE/api/v1/whatsmyip"
# => {"ip":"203.0.113.10"} (в реальном стенде это и есть проверка через FIP)
# 3. Self-check: спросить у ВНЕШНЕГО (вне облака) IP-echo сервиса, каким
# адресом мы наружу выглядим — это делает сам агент, control-api тут
# ни при чём (см. self_check.ip_echo_urls в validator-agent.yaml)
curl -s "https://api.ipify.org"
# => 203.0.113.10 (в реальном стенде это и есть проверка через FIP)
curl -s -X POST "$BASE/api/v1/agents/validator_01/self-check" \
-d '{"ip_id":1,"detected_egress_ip":"203.0.113.10","success":true,"detail":"matched"}'
+25 -16
View File
@@ -38,7 +38,7 @@ flowchart TB
end
subgraph CAPI["control-api (управляющая машина, 1 экземпляр)"]
HTTP["HTTP API<br/>/api/v1/agents/*<br/>/api/v1/probers/*<br/>/api/v1/admin/*<br/>/healthz · /whatsmyip"]
HTTP["HTTP API<br/>/api/v1/agents/*<br/>/api/v1/probers/*<br/>/api/v1/admin/*<br/>/healthz"]
ORCH["Оркестратор: Tick раз в<br/>poll_interval_seconds<br/>claim → associate FIP →<br/>ожидание self-check →<br/>checking → aggregate → release<br/>+ lease sweep + heartbeat sweep"]
DB[("SQLite<br/>validators / ip_queue<br/>checks / events")]
OSCLIENT["OpenStack-клиент<br/>(mode: mock | real)"]
@@ -85,8 +85,9 @@ flowchart LR
AGENT["validator-agent"]
end
CAPI["control-api"]
FIP(["Floating IP<br/>203.0.113.10<br/>(адрес под проверкой,<br/>привязан оркестратором заранее)"])
IPECHO["Внешний IP-echo сервис<br/>(api.ipify.org и т.п.,<br/>вне облака)"]
CAPI["control-api"]
subgraph TARGETS["Целевые серверы (targets из конфига)"]
T1["hub.docker.com"]
@@ -94,25 +95,33 @@ flowchart LR
T3["packages.ubuntu.com"]
end
AGENT -->|"1 GET /api/v1/whatsmyip<br/>(self-check)"| CAPI
CAPI -.->|"2 наблюдаемый исходящий IP"| AGENT
AGENT ==>|"3 весь исходящий трафик ВМ<br/>идёт через FIP (SNAT облака)"| FIP
AGENT ==>|"1 self-check: исходящий трафик<br/>ВМ идёт через FIP (SNAT облака)"| FIP
FIP ==>|"1 GET"| IPECHO
IPECHO -.->|"2 наблюдаемый исходящий IP"| AGENT
AGENT -->|"3 POST self-check {success}"| CAPI
AGENT ==>|"4 проверки: тоже через FIP"| FIP
FIP ==>|"4 HTTPS GET"| T1
FIP ==>|"4 HTTPS GET"| T2
FIP ==>|"4 ICMP echo"| T3
```
**Пояснение.** Шаги 1–2 — самопроверка (self-check): агент обращается к
`control-api` и сравнивает адрес, с которого пришёл его собственный
запрос, с адресом, который ему назначен. Поскольку весь исходящий трафик
ВМ реально идёт через привязанный Floating IP (шаг 3, SNAT на стороне
облака), совпадение подтверждает, что назначение применилось корректно —
только после этого агент переходит к шагу 4 и выполняет проверки из
`check_config` (HTTPS/ICMP/опционально SSH) против целей из конфига.
Каждый результат отправляется обратно в control-api сразу после
выполнения (см. диаграмму телеметрии ниже) — сам этот data-plane трафик
(шаги 3–4) в control-api не проходит и им не наблюдается напрямую,
control-api видит только заявленный агентом результат.
**Пояснение.** Шаги 1–2 — самопроверка (self-check): агент сам (без
участия control-api) обращается к внешнему IP-echo сервису и сравнивает
адрес, с которого пришёл его собственный запрос, с адресом, который ему
назначен. Ресурс для сравнения обязан быть вне облака: OpenStack
применяет SNAT через Floating IP только к трафику, уходящему через
внешнюю сеть — обращение к чему-либо внутри проекта (включая сам
control-api, если он в той же внутренней сети) показало бы приватный
адрес валидатора независимо от корректности привязки FIP. Итог
самопроверки агент затем сообщает control-api отдельным вызовом (шаг 3) —
это уже управляющий, а не проверяемый трафик. Только после успешного
self-check агент переходит к шагу 4 и выполняет проверки из
`check_config` (HTTPS/ICMP/опционально SSH) против целей из конфига —
тем же путём, через тот же FIP. Каждый результат отправляется обратно в
control-api сразу после выполнения (см. диаграмму телеметрии ниже) — сам
этот data-plane трафик (шаги 1 и 4) в control-api не проходит и им не
наблюдается напрямую, control-api видит только заявленный агентом
результат.
### Дополнительно: обратное направление (пробер к валидатору)
+19 -15
View File
@@ -8,31 +8,35 @@ real OpenStack cloud and no real internet access:
synthetic floating IP per configured address (see
`cmd/control-api/main.go`'s `newOpenStackClient`).
- **1 validator-agent** (`validator_01`), started with
`-stub-ports 12022,18081,18443,18888` — trivial accept-and-close TCP
`-stub-ports 22022,28081,28443,28888` — trivial accept-and-close TCP
listeners standing in for the base-minimum services (22/80/443/8080) a
real validator would run. ICMP needs no stub: the kernel answers echo
requests to any local address (127.0.0.0/8) on its own.
- **3 probers** (`site-1`/`site-2`/`site-3`), all probing `127.0.0.1`.
- **3 `httpstub` instances** (`scripts/httpstub`) standing in for the real
outbound targets (hub.docker.com / github.com / packages.ubuntu.com),
always returning `200 OK`.
- **3 `httpstub` instances** (`scripts/httpstub`) — `/` always returns
`200 OK`, standing in for the real outbound egress targets (hub.docker.com
/ github.com / packages.ubuntu.com); `/ip` echoes the caller's remote
address as plain text, standing in for the external IP-echo service the
validator-agent's self-check normally queries in production (see
`internal/agentcore.detectPublicIP` and `config.SelfCheckCfg.IPEchoURLs`).
The generated config uses a short `lease_ttl_seconds: 8` and
`checking_window_seconds: 15` so the whole run finishes in well under a
minute instead of using the (much longer) production defaults.
**Why only one IP address (`127.0.0.1`), and why it must be exactly that
one:** the self-check mechanism (`GET /whatsmyip`, see
`internal/httpapi/server.go`'s `remoteIP`) compares the TCP source address
the validator-agent's own outbound connection to control-api arrives with
against the address it was just assigned. In a real deployment, Neutron
actually SNATs the validator's egress traffic through whichever floating
IP is attached, so any configured address self-checks correctly. This
offline harness has no real network-level SNAT — the agent's traffic to
control-api always really originates from `127.0.0.1` — so only that
literal loopback address can ever pass self-check here. This is a
limitation of the harness's fidelity, not of the self-check mechanism
itself.
one:** self-check compares the source address the validator-agent's own
request to the IP-echo target arrives with against the address it was
just assigned. In a real deployment that target is a real external
IP-echo service, and Neutron actually SNATs the validator's egress
traffic through whichever floating IP is attached, so any configured
address self-checks correctly. This offline harness points
`self_check.ip_echo_urls` at the local `httpstub`'s `/ip` route instead
(see `validator-agent.yaml` generated by the script) — there's no real
network-level SNAT here, the agent's traffic to that stub always really
originates from `127.0.0.1`, so only that literal loopback address can
ever pass self-check in this harness. This is a limitation of the
harness's fidelity, not of the self-check mechanism itself.
## Running it
+7
View File
@@ -367,6 +367,13 @@ curl -s http://<control-api>:8080/api/v1/admin/validators | python3 -m json.tool
- `validator-agent` → интернет: HTTPS/ICMP до целей из `targets` конфига
(по умолчанию hub.docker.com, github.com, packages.ubuntu.com) — именно
через floating IP, который в данный момент привязан к валидатору.
- `validator-agent` → внешние IP-echo сервисы из `self_check.ip_echo_urls`
(по умолчанию `api.ipify.org`, `ifconfig.me`) — **обязательно вне
облака**: это и есть механизм self-check (см.
[DIAGRAMS.md](DIAGRAMS.md#2-поток-данных-от-валидатора-к-целевому-серверу-egress-проверка)).
Если валидатор не может достучаться ни до одного из этих адресов,
self-check никогда не пройдёт и IP будет бесконечно возвращаться в
очередь — см. [USAGE.md](USAGE.md#частые-проблемы-и-что-с-ними-делать).
- `control-api` → OpenStack Keystone/Neutron API (`OS_AUTH_URL` и далее по
каталогу сервисов).
+20 -2
View File
@@ -230,14 +230,32 @@ curl -s http://<control-api>:8080/api/v1/admin/validators | python3 -m json.tool
`server.listen_addr`) и что процесс `validator-agent` вообще запущен
(`systemctl status validator-agent`, `journalctl -u validator-agent`).
**Адрес постоянно проваливает self-check.**
**Адрес не выходит из `awaiting_self_check` (статус не меняется вообще).**
Self-check запрашивает внешние (вне облака) сервисы из
`self_check.ip_echo_urls` в конфиге валидатора (по умолчанию
`api.ipify.org`, `ifconfig.me`) — если у ВМ-валидатора нет исходящего
доступа в интернет к этим адресам, запрос не проходит вообще, и агент
даже не может *сообщить* результат control-api (ни успешный, ни
неуспешный) — тогда статус реально зависает до истечения
`orchestrator.lease_ttl_seconds`, после чего адрес возвращается в
`queued` и цикл повторяется. Проверьте связность до
`self_check.ip_echo_urls` прямо с ВМ-валидатора (`curl -s
https://api.ipify.org`) и логи `journalctl -u validator-agent` на предмет
`ip echo request failed`.
**Адрес постоянно проваливает self-check (не зависает, а именно
возвращается в очередь снова и снова).**
Смотрите `events` по адресу (`GET /api/v1/admin/ips/{ip}`) — в детали
события `self_check_result` будет указан обнаруженный исходящий адрес.
Если он не совпадает с ожидаемым — вероятно, на ВМ-валидаторе есть другой
маршрут наружу (не через назначенный Floating IP), либо привязка FIP на
стороне OpenStack не применилась. Проверьте вручную в OpenStack
(`openstack floating ip show <адрес>`), что `port_id` совпадает с портом
валидатора.
валидатора. Обратите внимание: адрес для сравнения обязан быть **вне**
облака (см. `self_check.ip_echo_urls`) — запрос к чему-либо внутри
проекта (в том числе к самому control-api, если он в той же внутренней
сети) покажет приватный адрес валидатора независимо от того, правильно
ли привязан FIP, и всегда будет давать ложный провал.
**Площадка (`site-N`) никогда не отчитывается (`SiteNComplete` всегда
`false`).**
+62 -8
View File
@@ -11,8 +11,12 @@ package agentcore
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"strings"
"time"
"cloudipvalidator/internal/apiclient"
@@ -139,19 +143,16 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
selfCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var whoami struct {
IP string `json:"ip"`
}
_, err := a.client.Do(selfCtx, "GET", "/api/v1/whatsmyip", nil, &whoami)
success := err == nil && whoami.IP == assignment.IPAddress
detectedIP, err := a.detectPublicIP(selfCtx)
success := err == nil && detectedIP == assignment.IPAddress
detail := "matched"
if err != nil {
detail = "whatsmyip request failed: " + err.Error()
detail = "ip echo request failed: " + err.Error()
} else if !success {
detail = fmt.Sprintf("egress ip %q does not match assigned fip %q", whoami.IP, assignment.IPAddress)
detail = fmt.Sprintf("egress ip %q does not match assigned fip %q", detectedIP, assignment.IPAddress)
}
a.postSelfCheck(ctx, assignment.IPID, whoami.IP, success, detail)
a.postSelfCheck(ctx, assignment.IPID, detectedIP, success, detail)
a.postEvent(ctx, assignment.IPID, "self_check_result", fmt.Sprintf(`{"success":%t}`, success))
if !success {
@@ -161,6 +162,59 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
a.runChecks(ctx, assignment)
}
// detectPublicIP asks each configured IP-echo URL, in order, for the
// address this validator is currently seen egressing from, returning the
// first one that answers with a parseable IP. These must be resources
// genuinely outside the cloud project (see config.SelfCheckCfg) — OpenStack
// only applies floating-IP SNAT to traffic leaving via the external
// network, so anything reachable over the project's internal network would
// report the validator's private address instead, regardless of whether
// the floating IP is correctly attached.
func (a *Agent) detectPublicIP(ctx context.Context) (string, error) {
var lastErr error
for _, url := range a.cfg.SelfCheck.IPEchoURLs {
ip, err := fetchIPEcho(ctx, url)
if err != nil {
lastErr = fmt.Errorf("%s: %w", url, err)
continue
}
return ip, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no self_check.ip_echo_urls configured")
}
return "", lastErr
}
// fetchIPEcho performs a single GET against an IP-echo endpoint that
// returns the caller's address as a bare string in the response body
// (the common contract shared by services like api.ipify.org,
// ifconfig.me/ip, icanhazip.com — and by the local stub used in
// scripts/run-local-e2e.sh).
func fetchIPEcho(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
ip := strings.TrimSpace(string(body))
if net.ParseIP(ip) == nil {
return "", fmt.Errorf("response is not a valid IP: %q", ip)
}
return ip, nil
}
func (a *Agent) runChecks(ctx context.Context, assignment assignmentResp) {
var results []checkResultDTO
for _, ct := range assignment.CheckConfig {
+14 -1
View File
@@ -175,8 +175,18 @@ type ValidatorAgent struct {
Checks AgentChecks `yaml:"checks"`
}
// SelfCheckCfg configures how the agent confirms its egress actually flows
// through the newly assigned floating IP. This must query a resource
// genuinely outside the cloud project: OpenStack only applies floating-IP
// SNAT to traffic leaving via the external/provider network, so any
// in-project resource (including control-api, if it's reachable over the
// project's internal network) would see the validator's private address
// instead — a false negative that never changes. IPEchoURLs are tried in
// order (falling through to the next on error/timeout, not on a genuine
// mismatch) until one returns a parseable IP.
type SelfCheckCfg struct {
TimeoutSeconds int `yaml:"timeout_seconds"`
TimeoutSeconds int `yaml:"timeout_seconds"`
IPEchoURLs []string `yaml:"ip_echo_urls"`
}
type AgentChecks struct {
@@ -202,6 +212,9 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
if c.SelfCheck.TimeoutSeconds == 0 {
c.SelfCheck.TimeoutSeconds = 10
}
if len(c.SelfCheck.IPEchoURLs) == 0 {
c.SelfCheck.IPEchoURLs = []string{"https://api.ipify.org", "https://ifconfig.me/ip"}
}
if c.Checks.HTTPSTimeoutSeconds == 0 {
c.Checks.HTTPSTimeoutSeconds = 10
}
-4
View File
@@ -139,7 +139,3 @@ func (s *Server) handleAgentComplete(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleWhatsMyIP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ip": remoteIP(r)})
}
+4 -7
View File
@@ -123,13 +123,10 @@ func TestEndToEndHTTPFlow(t *testing.T) {
t.Fatalf("expected 1.2.3.4, got %s", assignment.IPAddress)
}
// Self-check via /whatsmyip: in the real deployment this would equal
// the FIP; here we just exercise the endpoint and always report success.
resp, body = fc.do(http.MethodGet, "/api/v1/whatsmyip", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("whatsmyip: status=%d body=%s", resp.StatusCode, body)
}
// Self-check: in the real deployment the agent queries an external
// IP-echo service (see internal/agentcore.detectPublicIP) and compares
// the result to the assigned FIP; the HTTP layer here just accepts
// whatever outcome the caller reports.
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/self-check", selfCheckRequest{
IPID: assignment.IPID, DetectedEgress: "1.2.3.4", Success: true, Detail: "matched",
})
-1
View File
@@ -4,7 +4,6 @@ import "net/http"
func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /api/v1/whatsmyip", s.handleWhatsMyIP)
mux.HandleFunc("POST /api/v1/agents/register", s.handleAgentRegister)
mux.HandleFunc("POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat)
-13
View File
@@ -8,7 +8,6 @@ package httpapi
import (
"encoding/json"
"log/slog"
"net"
"net/http"
"cloudipvalidator/internal/db"
@@ -57,15 +56,3 @@ func readJSON(r *http.Request, v interface{}) error {
dec := json.NewDecoder(r.Body)
return dec.Decode(v)
}
// remoteIP returns the caller's source IP with any port stripped. Used by
// /whatsmyip — the validator-agent's self-check mechanism relies on this
// being the actual TCP peer address (as SNAT'd by the newly associated
// FIP), never a client-supplied header.
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+24 -4
View File
@@ -1,12 +1,24 @@
// Command httpstub is a trivial "always 200 OK" HTTP server used only by
// scripts/run-local-e2e.sh, standing in for the real internet targets
// (hub.docker.com, github.com, packages.ubuntu.com) so the offline
// end-to-end harness needs no real internet access.
// Command httpstub is a trivial local HTTP server used only by
// scripts/run-local-e2e.sh, standing in for two kinds of real internet
// resources so the offline end-to-end harness needs no real internet
// access:
// - "/" always returns 200 OK — stands in for the real outbound egress
// targets (hub.docker.com, github.com, packages.ubuntu.com).
// - "/ip" echoes the caller's remote address as plain text — stands in
// for the external IP-echo service the validator-agent's self-check
// queries in production (see internal/agentcore.detectPublicIP and
// config.SelfCheckCfg.IPEchoURLs). Because httpstub runs locally, this
// only produces a meaningful self-check signal in the harness because
// the "floating IP" under test is itself the loopback address the
// caller genuinely connects from — it is not a stand-in for OpenStack's
// SNAT behavior.
package main
import (
"flag"
"fmt"
"log"
"net"
"net/http"
)
@@ -18,6 +30,14 @@ func main() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("stub ok\n"))
})
http.HandleFunc("/ip", func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintln(w, host)
})
log.Printf("httpstub listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, nil))
}
+6
View File
@@ -102,6 +102,12 @@ control_api_url: "http://127.0.0.1:28080"
poll_interval_seconds: 1
self_check:
timeout_seconds: 5
# Points at the local httpstub's /ip echo route rather than a real
# internet IP-echo service — self-check only produces a meaningful
# signal here because the "floating IP" under test (127.0.0.1) is
# genuinely the address this process connects from; there's no real
# OpenStack SNAT involved in this offline harness. See docs/LOCAL_E2E.md.
ip_echo_urls: ["http://127.0.0.1:29091/ip"]
checks:
https_timeout_seconds: 5
icmp_timeout_seconds: 3