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>
This commit is contained in:
1 parent
146259cabb
commit
abbee9a08a
23 files changed
+765
-35
No files matched your search
@@ -97,8 +97,8 @@ func TestRouteTableIsClassified(t *testing.T) {
|
||||
t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access)
|
||||
}
|
||||
}
|
||||
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 7 {
|
||||
t.Fatalf("access counts = %v, want admin=34 agent=5 open=7", counts)
|
||||
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 8 {
|
||||
t.Fatalf("access counts = %v, want admin=34 agent=5 open=8", counts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ type okResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type observedIPResponse struct {
|
||||
IP string `json:"ip"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type checkConfigDTO struct {
|
||||
Type string `json:"type"`
|
||||
Targets []string `json:"targets"`
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"cloudipvalidator/internal/db"
|
||||
@@ -64,6 +69,46 @@ func (s *Server) handleAgentAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleAgentObservedIP tells a validator which source address control-api
|
||||
// sees for its connection, so the agent's self-check can confirm the floating
|
||||
// IP without a third-party IP-echo service. The route is open, so it is
|
||||
// limited to known validators to keep it from becoming a public "what is my
|
||||
// IP" service.
|
||||
func (s *Server) handleAgentObservedIP(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, err := s.DB.GetValidator(r.Context(), id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, db.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "unknown validator: "+id)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
ip, err := clientIP(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, observedIPResponse{IP: ip, Source: "remote_addr"})
|
||||
}
|
||||
|
||||
// clientIP returns the peer address of the TCP connection in canonical form
|
||||
// (IPv4-mapped IPv6 unmapped to plain IPv4). It deliberately ignores
|
||||
// X-Forwarded-For / X-Real-IP: validators connect directly, and trusting a
|
||||
// client-supplied header would let a validator forge the address and pass
|
||||
// the self-check.
|
||||
func clientIP(r *http.Request) (string, error) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse remote address %q: %w", r.RemoteAddr, err)
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse remote address %q: %w", r.RemoteAddr, err)
|
||||
}
|
||||
return addr.Unmap().String(), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleAgentSelfCheck(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req selfCheckRequest
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func (s *Server) routeTable() []route {
|
||||
{"POST /api/v1/agents/register", s.handleAgentRegister, accessOpen},
|
||||
{"POST /api/v1/agents/{id}/heartbeat", s.handleAgentHeartbeat, accessOpen},
|
||||
{"GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment, accessOpen},
|
||||
{"GET /api/v1/agents/{id}/observed-ip", s.handleAgentObservedIP, accessOpen},
|
||||
{"POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck, accessAgent},
|
||||
{"POST /api/v1/agents/{id}/events", s.handleAgentEvent, accessAgent},
|
||||
{"POST /api/v1/agents/{id}/results", s.handleAgentResults, accessAgent},
|
||||
|
||||
Reference in new issue
Block a user