diff --git a/bin/SHA256SUMS b/bin/SHA256SUMS index 84d0db6..48219e8 100644 --- a/bin/SHA256SUMS +++ b/bin/SHA256SUMS @@ -1,3 +1,3 @@ -9a6e8dd5d9be684cd4b9c26dde273c3ce863fc9bf869b2e5811a22e14e7bdc98 control-api -8793491ae048eb57fb4d901a762e8518fc8253e6cecbf371425cbe9d83c46db9 validator-agent -3e9c8b4878aed09e4e946a1c700f6c52b8c5395da33199197097661a4a758bf3 prober +a7c481f3c8421dc8b3985d7c3eda13a0ad1e5d37c7a58a4aaf37c1b18254ff54 control-api +2e34dce04889a25c564bbd0dd9dfc26499c9e63486f8419ff622ca34f7a9bed8 validator-agent +e6ec2444854f624b11f917b7d3cf9fa32f9ee49aff0da637fe2e59140e02133c prober diff --git a/bin/control-api b/bin/control-api index 965b963..db73f1d 100755 Binary files a/bin/control-api and b/bin/control-api differ diff --git a/bin/prober b/bin/prober index 33bb097..cf4725e 100755 Binary files a/bin/prober and b/bin/prober differ diff --git a/bin/validator-agent b/bin/validator-agent index 71bad4e..5d1a746 100755 Binary files a/bin/validator-agent and b/bin/validator-agent differ diff --git a/configs/validator-agent.example.yaml b/configs/validator-agent.example.yaml index 8a7f6bb..5f3bd66 100644 --- a/configs/validator-agent.example.yaml +++ b/configs/validator-agent.example.yaml @@ -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 diff --git a/docs/API.md b/docs/API.md index f4406d9..666676b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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"}' diff --git a/docs/DIAGRAMS.md b/docs/DIAGRAMS.md index 6669b5e..c76353b 100644 --- a/docs/DIAGRAMS.md +++ b/docs/DIAGRAMS.md @@ -38,7 +38,7 @@ flowchart TB end subgraph CAPI["control-api (управляющая машина, 1 экземпляр)"] - HTTP["HTTP API
/api/v1/agents/*
/api/v1/probers/*
/api/v1/admin/*
/healthz · /whatsmyip"] + HTTP["HTTP API
/api/v1/agents/*
/api/v1/probers/*
/api/v1/admin/*
/healthz"] ORCH["Оркестратор: Tick раз в
poll_interval_seconds
claim → associate FIP →
ожидание self-check →
checking → aggregate → release
+ lease sweep + heartbeat sweep"] DB[("SQLite
validators / ip_queue
checks / events")] OSCLIENT["OpenStack-клиент
(mode: mock | real)"] @@ -85,8 +85,9 @@ flowchart LR AGENT["validator-agent"] end - CAPI["control-api"] FIP(["Floating IP
203.0.113.10
(адрес под проверкой,
привязан оркестратором заранее)"]) + IPECHO["Внешний IP-echo сервис
(api.ipify.org и т.п.,
вне облака)"] + 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
(self-check)"| CAPI - CAPI -.->|"2 наблюдаемый исходящий IP"| AGENT - AGENT ==>|"3 весь исходящий трафик ВМ
идёт через FIP (SNAT облака)"| FIP + AGENT ==>|"1 self-check: исходящий трафик
ВМ идёт через 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 видит только заявленный агентом +результат. ### Дополнительно: обратное направление (пробер к валидатору) diff --git a/docs/LOCAL_E2E.md b/docs/LOCAL_E2E.md index f9dfccd..64e9c84 100644 --- a/docs/LOCAL_E2E.md +++ b/docs/LOCAL_E2E.md @@ -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 diff --git a/docs/SETUP.md b/docs/SETUP.md index 9b64a30..1f44b1c 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -367,6 +367,13 @@ curl -s http://: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` и далее по каталогу сервисов). diff --git a/docs/USAGE.md b/docs/USAGE.md index 364e56d..4caa4e1 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -230,14 +230,32 @@ curl -s http://: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`).** diff --git a/internal/agentcore/agentcore.go b/internal/agentcore/agentcore.go index 40c113f..0c937c6 100644 --- a/internal/agentcore/agentcore.go +++ b/internal/agentcore/agentcore.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 6347ba7..7c792a3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/httpapi/handlers_agent.go b/internal/httpapi/handlers_agent.go index 6275d0b..5e974bc 100644 --- a/internal/httpapi/handlers_agent.go +++ b/internal/httpapi/handlers_agent.go @@ -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)}) -} diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index d66bc7b..9fd18de 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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", }) diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index d36b649..4ca82fc 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index d7b880c..fb43180 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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 -} diff --git a/scripts/httpstub/main.go b/scripts/httpstub/main.go index 9c51b7b..0f6a5cb 100644 --- a/scripts/httpstub/main.go +++ b/scripts/httpstub/main.go @@ -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)) } diff --git a/scripts/run-local-e2e.sh b/scripts/run-local-e2e.sh index 2479abb..8428bf7 100755 --- a/scripts/run-local-e2e.sh +++ b/scripts/run-local-e2e.sh @@ -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