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 a7c481f3c8421dc8b3985d7c3eda13a0ad1e5d37c7a58a4aaf37c1b18254ff54 control-api
8793491ae048eb57fb4d901a762e8518fc8253e6cecbf371425cbe9d83c46db9 validator-agent 2e34dce04889a25c564bbd0dd9dfc26499c9e63486f8419ff622ca34f7a9bed8 validator-agent
3e9c8b4878aed09e4e946a1c700f6c52b8c5395da33199197097661a4a758bf3 prober 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: self_check:
timeout_seconds: 10 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: checks:
https_timeout_seconds: 10 https_timeout_seconds: 10
+14 -16
View File
@@ -113,8 +113,15 @@ JSON, базовый префикс прикладных методов — `/ap
Отчёт о результате self-check — подтверждение, что исходящий трафик Отчёт о результате self-check — подтверждение, что исходящий трафик
валидатора действительно идёт через только что назначенный FIP. Агент валидатора действительно идёт через только что назначенный FIP. Агент
определяет это, вызвав `GET /api/v1/whatsmyip` и сравнив ответ с определяет это **сам**, обращаясь к внешнему (снаружи облака) IP-echo
`ip_address` из задания. сервису (`self_check.ip_echo_urls` в `validator-agent.yaml`, например
`api.ipify.org`) и сравнивая ответ с `ip_address` из задания — control-api
в этом определении не участвует. Важно, что ресурс должен быть именно
внешним: OpenStack применяет SNAT через Floating IP только к трафику,
уходящему через внешнюю сеть, поэтому обращение к чему-либо внутри
проекта (в том числе к самому control-api, если он в той же внутренней
сети) покажет приватный адрес валидатора независимо от того, правильно
ли привязан FIP.
Запрос: Запрос:
```json ```json
@@ -247,17 +254,6 @@ IP на данном проходе". До этого момента control-api
Проверка живости процесса. Ответ: `{"ok": true}`. Используется в systemd/ Проверка живости процесса. Ответ: `{"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` ### `GET /api/v1/admin/status`
Сводка по очереди — сколько IP в каком состоянии. Сводка по очереди — сколько IP в каком состоянии.
@@ -348,9 +344,11 @@ curl -s -X POST "$BASE/api/v1/agents/register" \
curl -s "$BASE/api/v1/agents/validator_01/assignment" curl -s "$BASE/api/v1/agents/validator_01/assignment"
# => {"ip_id":1,"ip_address":"203.0.113.10","phase":"awaiting_self_check","check_config":[...]} # => {"ip_id":1,"ip_address":"203.0.113.10","phase":"awaiting_self_check","check_config":[...]}
# 3. Self-check: спросить у control-api, каким адресом мы к нему пришли # 3. Self-check: спросить у ВНЕШНЕГО (вне облака) IP-echo сервиса, каким
curl -s "$BASE/api/v1/whatsmyip" # адресом мы наружу выглядим — это делает сам агент, control-api тут
# => {"ip":"203.0.113.10"} (в реальном стенде это и есть проверка через FIP) # ни при чём (см. 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" \ 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"}' -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 end
subgraph CAPI["control-api (управляющая машина, 1 экземпляр)"] 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"] 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")] DB[("SQLite<br/>validators / ip_queue<br/>checks / events")]
OSCLIENT["OpenStack-клиент<br/>(mode: mock | real)"] OSCLIENT["OpenStack-клиент<br/>(mode: mock | real)"]
@@ -85,8 +85,9 @@ flowchart LR
AGENT["validator-agent"] AGENT["validator-agent"]
end end
CAPI["control-api"]
FIP(["Floating IP<br/>203.0.113.10<br/>(адрес под проверкой,<br/>привязан оркестратором заранее)"]) FIP(["Floating IP<br/>203.0.113.10<br/>(адрес под проверкой,<br/>привязан оркестратором заранее)"])
IPECHO["Внешний IP-echo сервис<br/>(api.ipify.org и т.п.,<br/>вне облака)"]
CAPI["control-api"]
subgraph TARGETS["Целевые серверы (targets из конфига)"] subgraph TARGETS["Целевые серверы (targets из конфига)"]
T1["hub.docker.com"] T1["hub.docker.com"]
@@ -94,25 +95,33 @@ flowchart LR
T3["packages.ubuntu.com"] T3["packages.ubuntu.com"]
end end
AGENT -->|"1 GET /api/v1/whatsmyip<br/>(self-check)"| CAPI AGENT ==>|"1 self-check: исходящий трафик<br/>ВМ идёт через FIP (SNAT облака)"| FIP
CAPI -.->|"2 наблюдаемый исходящий IP"| AGENT FIP ==>|"1 GET"| IPECHO
AGENT ==>|"3 весь исходящий трафик ВМ<br/>идёт через FIP (SNAT облака)"| FIP IPECHO -.->|"2 наблюдаемый исходящий IP"| AGENT
AGENT -->|"3 POST self-check {success}"| CAPI
AGENT ==>|"4 проверки: тоже через FIP"| FIP
FIP ==>|"4 HTTPS GET"| T1 FIP ==>|"4 HTTPS GET"| T1
FIP ==>|"4 HTTPS GET"| T2 FIP ==>|"4 HTTPS GET"| T2
FIP ==>|"4 ICMP echo"| T3 FIP ==>|"4 ICMP echo"| T3
``` ```
**Пояснение.** Шаги 1–2 — самопроверка (self-check): агент обращается к **Пояснение.** Шаги 1–2 — самопроверка (self-check): агент сам (без
`control-api` и сравнивает адрес, с которого пришёл его собственный участия control-api) обращается к внешнему IP-echo сервису и сравнивает
запрос, с адресом, который ему назначен. Поскольку весь исходящий трафик адрес, с которого пришёл его собственный запрос, с адресом, который ему
ВМ реально идёт через привязанный Floating IP (шаг 3, SNAT на стороне назначен. Ресурс для сравнения обязан быть вне облака: OpenStack
облака), совпадение подтверждает, что назначение применилось корректно — применяет SNAT через Floating IP только к трафику, уходящему через
только после этого агент переходит к шагу 4 и выполняет проверки из внешнюю сеть — обращение к чему-либо внутри проекта (включая сам
`check_config` (HTTPS/ICMP/опционально SSH) против целей из конфига. control-api, если он в той же внутренней сети) показало бы приватный
Каждый результат отправляется обратно в control-api сразу после адрес валидатора независимо от корректности привязки FIP. Итог
выполнения (см. диаграмму телеметрии ниже) — сам этот data-plane трафик самопроверки агент затем сообщает control-api отдельным вызовом (шаг 3) —
(шаги 3–4) в control-api не проходит и им не наблюдается напрямую, это уже управляющий, а не проверяемый трафик. Только после успешного
control-api видит только заявленный агентом результат. 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 synthetic floating IP per configured address (see
`cmd/control-api/main.go`'s `newOpenStackClient`). `cmd/control-api/main.go`'s `newOpenStackClient`).
- **1 validator-agent** (`validator_01`), started with - **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 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 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. 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 probers** (`site-1`/`site-2`/`site-3`), all probing `127.0.0.1`.
- **3 `httpstub` instances** (`scripts/httpstub`) standing in for the real - **3 `httpstub` instances** (`scripts/httpstub`) — `/` always returns
outbound targets (hub.docker.com / github.com / packages.ubuntu.com), `200 OK`, standing in for the real outbound egress targets (hub.docker.com
always returning `200 OK`. / 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 The generated config uses a short `lease_ttl_seconds: 8` and
`checking_window_seconds: 15` so the whole run finishes in well under a `checking_window_seconds: 15` so the whole run finishes in well under a
minute instead of using the (much longer) production defaults. 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 **Why only one IP address (`127.0.0.1`), and why it must be exactly that
one:** the self-check mechanism (`GET /whatsmyip`, see one:** self-check compares the source address the validator-agent's own
`internal/httpapi/server.go`'s `remoteIP`) compares the TCP source address request to the IP-echo target arrives with against the address it was
the validator-agent's own outbound connection to control-api arrives with just assigned. In a real deployment that target is a real external
against the address it was just assigned. In a real deployment, Neutron IP-echo service, and Neutron actually SNATs the validator's egress
actually SNATs the validator's egress traffic through whichever floating traffic through whichever floating IP is attached, so any configured
IP is attached, so any configured address self-checks correctly. This address self-checks correctly. This offline harness points
offline harness has no real network-level SNAT — the agent's traffic to `self_check.ip_echo_urls` at the local `httpstub`'s `/ip` route instead
control-api always really originates from `127.0.0.1` — so only that (see `validator-agent.yaml` generated by the script) — there's no real
literal loopback address can ever pass self-check here. This is a network-level SNAT here, the agent's traffic to that stub always really
limitation of the harness's fidelity, not of the self-check mechanism originates from `127.0.0.1`, so only that literal loopback address can
itself. 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 ## 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` конфига - `validator-agent` → интернет: HTTPS/ICMP до целей из `targets` конфига
(по умолчанию hub.docker.com, github.com, packages.ubuntu.com) — именно (по умолчанию hub.docker.com, github.com, packages.ubuntu.com) — именно
через floating IP, который в данный момент привязан к валидатору. через 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` и далее по - `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` вообще запущен `server.listen_addr`) и что процесс `validator-agent` вообще запущен
(`systemctl status validator-agent`, `journalctl -u 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}`) — в детали Смотрите `events` по адресу (`GET /api/v1/admin/ips/{ip}`) — в детали
события `self_check_result` будет указан обнаруженный исходящий адрес. события `self_check_result` будет указан обнаруженный исходящий адрес.
Если он не совпадает с ожидаемым — вероятно, на ВМ-валидаторе есть другой Если он не совпадает с ожидаемым — вероятно, на ВМ-валидаторе есть другой
маршрут наружу (не через назначенный Floating IP), либо привязка FIP на маршрут наружу (не через назначенный Floating IP), либо привязка FIP на
стороне OpenStack не применилась. Проверьте вручную в OpenStack стороне OpenStack не применилась. Проверьте вручную в OpenStack
(`openstack floating ip show <адрес>`), что `port_id` совпадает с портом (`openstack floating ip show <адрес>`), что `port_id` совпадает с портом
валидатора. валидатора. Обратите внимание: адрес для сравнения обязан быть **вне**
облака (см. `self_check.ip_echo_urls`) — запрос к чему-либо внутри
проекта (в том числе к самому control-api, если он в той же внутренней
сети) покажет приватный адрес валидатора независимо от того, правильно
ли привязан FIP, и всегда будет давать ложный провал.
**Площадка (`site-N`) никогда не отчитывается (`SiteNComplete` всегда **Площадка (`site-N`) никогда не отчитывается (`SiteNComplete` всегда
`false`).** `false`).**
+62 -8
View File
@@ -11,8 +11,12 @@ package agentcore
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net"
"net/http"
"os" "os"
"strings"
"time" "time"
"cloudipvalidator/internal/apiclient" "cloudipvalidator/internal/apiclient"
@@ -139,19 +143,16 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
selfCtx, cancel := context.WithTimeout(ctx, timeout) selfCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
var whoami struct { detectedIP, err := a.detectPublicIP(selfCtx)
IP string `json:"ip"` success := err == nil && detectedIP == assignment.IPAddress
}
_, err := a.client.Do(selfCtx, "GET", "/api/v1/whatsmyip", nil, &whoami)
success := err == nil && whoami.IP == assignment.IPAddress
detail := "matched" detail := "matched"
if err != nil { if err != nil {
detail = "whatsmyip request failed: " + err.Error() detail = "ip echo request failed: " + err.Error()
} else if !success { } 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)) a.postEvent(ctx, assignment.IPID, "self_check_result", fmt.Sprintf(`{"success":%t}`, success))
if !success { if !success {
@@ -161,6 +162,59 @@ func (a *Agent) handleSelfCheckAndRun(ctx context.Context, assignment assignment
a.runChecks(ctx, 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) { func (a *Agent) runChecks(ctx context.Context, assignment assignmentResp) {
var results []checkResultDTO var results []checkResultDTO
for _, ct := range assignment.CheckConfig { for _, ct := range assignment.CheckConfig {
+14 -1
View File
@@ -175,8 +175,18 @@ type ValidatorAgent struct {
Checks AgentChecks `yaml:"checks"` 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 { type SelfCheckCfg struct {
TimeoutSeconds int `yaml:"timeout_seconds"` TimeoutSeconds int `yaml:"timeout_seconds"`
IPEchoURLs []string `yaml:"ip_echo_urls"`
} }
type AgentChecks struct { type AgentChecks struct {
@@ -202,6 +212,9 @@ func LoadValidatorAgent(path string) (*ValidatorAgent, error) {
if c.SelfCheck.TimeoutSeconds == 0 { if c.SelfCheck.TimeoutSeconds == 0 {
c.SelfCheck.TimeoutSeconds = 10 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 { if c.Checks.HTTPSTimeoutSeconds == 0 {
c.Checks.HTTPSTimeoutSeconds = 10 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}) 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) t.Fatalf("expected 1.2.3.4, got %s", assignment.IPAddress)
} }
// Self-check via /whatsmyip: in the real deployment this would equal // Self-check: in the real deployment the agent queries an external
// the FIP; here we just exercise the endpoint and always report success. // IP-echo service (see internal/agentcore.detectPublicIP) and compares
resp, body = fc.do(http.MethodGet, "/api/v1/whatsmyip", nil) // the result to the assigned FIP; the HTTP layer here just accepts
if resp.StatusCode != http.StatusOK { // whatever outcome the caller reports.
t.Fatalf("whatsmyip: status=%d body=%s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/self-check", selfCheckRequest{ 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", 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) { func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", s.handleHealthz) 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/register", s.handleAgentRegister)
mux.HandleFunc("POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat) mux.HandleFunc("POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat)
-13
View File
@@ -8,7 +8,6 @@ package httpapi
import ( import (
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"cloudipvalidator/internal/db" "cloudipvalidator/internal/db"
@@ -57,15 +56,3 @@ func readJSON(r *http.Request, v interface{}) error {
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
return dec.Decode(v) 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 // Command httpstub is a trivial local HTTP server used only by
// scripts/run-local-e2e.sh, standing in for the real internet targets // scripts/run-local-e2e.sh, standing in for two kinds of real internet
// (hub.docker.com, github.com, packages.ubuntu.com) so the offline // resources so the offline end-to-end harness needs no real internet
// end-to-end harness needs no real internet access. // 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 package main
import ( import (
"flag" "flag"
"fmt"
"log" "log"
"net"
"net/http" "net/http"
) )
@@ -18,6 +30,14 @@ func main() {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("stub ok\n")) _, _ = 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.Printf("httpstub listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, nil)) 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 poll_interval_seconds: 1
self_check: self_check:
timeout_seconds: 5 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: checks:
https_timeout_seconds: 5 https_timeout_seconds: 5
icmp_timeout_seconds: 3 icmp_timeout_seconds: 3