Files
cloud-ip-validator/internal/httpapi/handlers_observedip_test.go
T
ayurishchevandClaude Sonnet 5.5 abbee9a08a Add self-check via control-api (self_check.methods)
control-api is hosted outside the cloud and validators reach it directly,
so it sees the floating IP as the connection's source address. New open
route GET /api/v1/agents/{id}/observed-ip returns that address (taken only
from the TCP peer; forwarding headers are ignored so a validator cannot
forge it).

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 03:24:20 +03:00

80 lines
2.5 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestClientIP(t *testing.T) {
cases := []struct {
name string
remoteAddr string
headers map[string]string
want string
wantErr bool
}{
{name: "ipv4", remoteAddr: "90.156.213.5:51234", want: "90.156.213.5"},
{name: "ipv4 mapped in ipv6", remoteAddr: "[::ffff:90.156.213.5]:51234", want: "90.156.213.5"},
{name: "ipv6", remoteAddr: "[2001:db8::7]:443", want: "2001:db8::7"},
// A client-supplied header must never change the answer.
{name: "forwarded for is ignored", remoteAddr: "90.156.213.5:1",
headers: map[string]string{"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "5.6.7.8"}, want: "90.156.213.5"},
{name: "no port", remoteAddr: "90.156.213.5", wantErr: true},
{name: "not an address", remoteAddr: "host:80", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.RemoteAddr = tc.remoteAddr
for k, v := range tc.headers {
r.Header.Set(k, v)
}
got, err := clientIP(r)
if tc.wantErr {
if err == nil {
t.Fatalf("expected an error, got %q", got)
}
return
}
if err != nil || got != tc.want {
t.Fatalf("clientIP = %q, %v; want %q", got, err, tc.want)
}
})
}
}
// The route is open (no token), answers a known validator with the address
// control-api sees, and refuses unknown validators and a forged header.
func TestObservedIPEndpoint(t *testing.T) {
fc, d, _, _ := newConfigTestHarness(t)
if err := d.RegisterValidator(context.Background(), "validator-1", "host-1", "port-1", "v0.1"); err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, fc.base+"/api/v1/agents/validator-1/observed-ip", nil)
req.Header.Set("X-Forwarded-For", "8.8.8.8")
resp, err := fc.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var got observedIPResponse
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
// httptest connects from loopback; the forged header must not leak through.
if got.IP != "127.0.0.1" || got.Source != "remote_addr" {
t.Fatalf("response = %+v, want 127.0.0.1 / remote_addr", got)
}
if resp, _ := fc.do(http.MethodGet, "/api/v1/agents/no-such-validator/observed-ip", nil); resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown validator: status = %d, want 404", resp.StatusCode)
}
}