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