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>
80 lines
2.5 KiB
Go
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)
|
|
}
|
|
}
|