repo init

This commit is contained in:
ayurishchev committed 2026-08-21 07:34:45 +03:00
commit 7e44db87b2
48 files changed
+5346

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
package httpapi
// Request/response bodies for the /api/v1 surface. Kept in one file since
// they're small and mostly 1:1 with a single handler each.
type registerAgentRequest struct {
ValidatorID string `json:"validator_id"`
Hostname string `json:"hostname"`
AgentVersion string `json:"agent_version"`
}
type registerAgentResponse struct {
OK bool `json:"ok"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
}
type heartbeatRequest struct {
LocalState string `json:"local_state"`
CurrentIPAddress string `json:"current_ip_address,omitempty"`
}
type okResponse struct {
OK bool `json:"ok"`
}
type checkConfigDTO struct {
Type string `json:"type"`
Targets []string `json:"targets"`
}
type assignmentResponse struct {
IPID int64 `json:"ip_id"`
IPAddress string `json:"ip_address"`
Phase string `json:"phase"`
CheckConfig []checkConfigDTO `json:"check_config,omitempty"`
}
type selfCheckRequest struct {
IPID int64 `json:"ip_id"`
DetectedEgress string `json:"detected_egress_ip"`
Success bool `json:"success"`
Detail string `json:"detail"`
}
type agentEventRequest struct {
EventType string `json:"event_type"`
IPID *int64 `json:"ip_id,omitempty"`
Payload string `json:"payload,omitempty"`
}
type checkResultDTO struct {
IPID int64 `json:"ip_id"`
CheckType string `json:"check_type"`
Target string `json:"target,omitempty"`
Success bool `json:"success"`
LatencyMS int64 `json:"latency_ms"`
Detail string `json:"detail,omitempty"`
CheckedAt string `json:"checked_at"`
}
type agentResultsRequest struct {
Results []checkResultDTO `json:"results"`
}
type agentCompleteRequest struct {
IPID int64 `json:"ip_id"`
}
type registerProberRequest struct {
SiteID string `json:"site_id"`
Hostname string `json:"hostname"`
}
type proberAssignment struct {
IPID int64 `json:"ip_id"`
IPAddress string `json:"ip_address"`
Ports []int `json:"ports"`
ICMP bool `json:"icmp"`
}
type proberResultDTO struct {
IPID int64 `json:"ip_id"`
IPAddress string `json:"ip_address"`
CheckType string `json:"check_type"`
Success bool `json:"success"`
LatencyMS int64 `json:"latency_ms"`
Detail string `json:"detail,omitempty"`
CheckedAt string `json:"checked_at"`
Complete bool `json:"complete"`
}
type proberResultsRequest struct {
Results []proberResultDTO `json:"results"`
}
type errorResponse struct {
Error string `json:"error"`
}
+75
View File
@@ -0,0 +1,75 @@
package httpapi
import (
"net/http"
"cloudipvalidator/internal/db"
)
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
ips, err := s.DB.ListIPs(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
validators, err := s.DB.ListValidators(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
byState := map[string]int{}
for _, ip := range ips {
byState[ip.State]++
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"total_ips": len(ips),
"ips_by_state": byState,
"total_validators": len(validators),
})
}
func (s *Server) handleAdminIPs(w http.ResponseWriter, r *http.Request) {
ips, err := s.DB.ListIPs(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, ips)
}
func (s *Server) handleAdminIPDetail(w http.ResponseWriter, r *http.Request) {
address := r.PathValue("ip")
item, err := s.DB.GetIPByAddress(r.Context(), address)
if err != nil {
writeError(w, http.StatusNotFound, "unknown ip: "+address)
return
}
checks, err := s.DB.ListChecksForAttempt(r.Context(), item.ID, item.AttemptNumber)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
events, err := s.DB.ListEventsForIP(r.Context(), item.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, struct {
IP *db.IPQueueItem `json:"ip"`
Checks []db.Check `json:"checks"`
Events []db.Event `json:"events"`
}{item, checks, events})
}
func (s *Server) handleAdminValidators(w http.ResponseWriter, r *http.Request) {
validators, err := s.DB.ListValidators(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, validators)
}
+145
View File
@@ -0,0 +1,145 @@
package httpapi
import (
"net/http"
"time"
"cloudipvalidator/internal/db"
)
func (s *Server) handleAgentRegister(w http.ResponseWriter, r *http.Request) {
var req registerAgentRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if req.ValidatorID == "" {
writeError(w, http.StatusBadRequest, "validator_id is required")
return
}
// os_port_id is supplied via control-api's own config (config.ValidatorConfig),
// not by the agent, so registration only touches hostname/version here;
// RegisterValidator preserves any existing os_port_id row.
existing, _ := s.DB.GetValidator(r.Context(), req.ValidatorID)
osPortID := ""
if existing != nil {
osPortID = existing.OSPortID
}
if err := s.DB.RegisterValidator(r.Context(), req.ValidatorID, req.Hostname, osPortID, req.AgentVersion); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.Orch.RecordEvent(r.Context(), "validator-agent", req.ValidatorID, nil, "registered", "")
writeJSON(w, http.StatusOK, registerAgentResponse{OK: true, PollIntervalSeconds: s.Orch.Cfg.PollIntervalSeconds})
}
func (s *Server) handleAgentHeartbeat(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req heartbeatRequest
_ = readJSON(r, &req) // heartbeat body is informational only; tolerate empty/missing
if err := s.DB.Heartbeat(r.Context(), id); err != nil {
writeError(w, http.StatusNotFound, "unknown validator: "+id)
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleAgentAssignment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
item, checks, err := s.Orch.AssignmentForValidator(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "unknown validator: "+id)
return
}
if item == nil {
w.WriteHeader(http.StatusNoContent)
return
}
var cfg []checkConfigDTO
for _, c := range checks {
cfg = append(cfg, checkConfigDTO{Type: c.Type, Targets: c.Targets})
}
writeJSON(w, http.StatusOK, assignmentResponse{
IPID: item.ID, IPAddress: item.IPAddress, Phase: item.State, CheckConfig: cfg,
})
}
func (s *Server) handleAgentSelfCheck(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req selfCheckRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
detail := req.Detail
if req.DetectedEgress != "" {
detail = "detected_egress_ip=" + req.DetectedEgress + " " + detail
}
if err := s.Orch.SelfCheckResult(r.Context(), id, req.IPID, req.Success, detail); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleAgentEvent(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req agentEventRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if req.EventType == "" {
writeError(w, http.StatusBadRequest, "event_type is required")
return
}
s.Orch.RecordEvent(r.Context(), "validator-agent", id, req.IPID, req.EventType, req.Payload)
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleAgentResults(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req agentResultsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
for _, res := range req.Results {
item, err := s.DB.GetIP(r.Context(), res.IPID)
if err != nil {
writeError(w, http.StatusNotFound, "unknown ip_id")
return
}
checkedAt, err := time.Parse(time.RFC3339Nano, res.CheckedAt)
if err != nil {
checkedAt = db.Now()
}
err = s.Orch.RecordCheck(r.Context(), db.Check{
IPID: item.ID, IPAddress: item.IPAddress, AttemptNumber: item.AttemptNumber,
ValidatorID: id, Source: db.SourceEgress, CheckType: res.CheckType, Target: res.Target,
Success: res.Success, LatencyMS: res.LatencyMS, Detail: res.Detail, CheckedAt: checkedAt,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (s *Server) handleAgentComplete(w http.ResponseWriter, r *http.Request) {
var req agentCompleteRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if err := s.Orch.MarkEgressComplete(r.Context(), req.IPID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
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)})
}
+92
View File
@@ -0,0 +1,92 @@
package httpapi
import (
"net/http"
"time"
"cloudipvalidator/internal/db"
)
func (s *Server) handleProberRegister(w http.ResponseWriter, r *http.Request) {
var req registerProberRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
if s.Orch.SiteIndexForID(req.SiteID) == 0 {
writeError(w, http.StatusBadRequest, "unknown site_id: "+req.SiteID)
return
}
s.Orch.RecordEvent(r.Context(), "prober", req.SiteID, nil, "registered", "")
writeJSON(w, http.StatusOK, registerAgentResponse{OK: true, PollIntervalSeconds: s.Orch.Cfg.PollIntervalSeconds})
}
// handleProberAssignments returns every IP currently in the checking
// state — probers work the whole active set each poll, not one IP at a
// time, since multiple validators run in parallel.
func (s *Server) handleProberAssignments(w http.ResponseWriter, r *http.Request) {
siteID := r.PathValue("site_id")
if s.Orch.SiteIndexForID(siteID) == 0 {
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
return
}
items, err := s.DB.ListChecking(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]proberAssignment, 0, len(items))
for _, item := range items {
out = append(out, proberAssignment{
IPID: item.ID, IPAddress: item.IPAddress,
Ports: s.Orch.Inbound.Ports, ICMP: s.Orch.Inbound.ICMP,
})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleProberResults(w http.ResponseWriter, r *http.Request) {
siteID := r.PathValue("site_id")
siteIndex := s.Orch.SiteIndexForID(siteID)
if siteIndex == 0 {
writeError(w, http.StatusNotFound, "unknown site_id: "+siteID)
return
}
var req proberResultsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: "+err.Error())
return
}
completed := map[int64]bool{}
for _, res := range req.Results {
item, err := s.DB.GetIP(r.Context(), res.IPID)
if err != nil {
writeError(w, http.StatusNotFound, "unknown ip_id")
return
}
checkedAt, err := time.Parse(time.RFC3339Nano, res.CheckedAt)
if err != nil {
checkedAt = db.Now()
}
err = s.Orch.RecordCheck(r.Context(), db.Check{
IPID: item.ID, IPAddress: item.IPAddress, AttemptNumber: item.AttemptNumber,
Source: db.InboundSource(siteIndex), CheckType: res.CheckType, Target: res.IPAddress,
Success: res.Success, LatencyMS: res.LatencyMS, Detail: res.Detail, CheckedAt: checkedAt,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if res.Complete {
completed[res.IPID] = true
}
}
for ipID := range completed {
if err := s.Orch.MarkSiteComplete(r.Context(), ipID, siteIndex); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
+210
View File
@@ -0,0 +1,210 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"cloudipvalidator/internal/config"
"cloudipvalidator/internal/db"
"cloudipvalidator/internal/openstack"
"cloudipvalidator/internal/orchestrator"
)
// fakeClient plays both a validator-agent and the three probers against a
// real httptest server, driving the full protocol exactly as the real
// binaries would, to prove the HTTP layer and orchestrator agree on state
// transitions end-to-end.
type fakeClient struct {
t *testing.T
base string
client *http.Client
}
func (f *fakeClient) do(method, path string, body interface{}) (*http.Response, []byte) {
f.t.Helper()
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
f.t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, f.base+path, reader)
if err != nil {
f.t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := f.client.Do(req)
if err != nil {
f.t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return resp, respBody
}
func TestEndToEndHTTPFlow(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "test.db")
d, err := db.Open(ctx, dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
defer d.Close()
mock := openstack.NewMockClient()
mock.Seed("fip-1", "1.2.3.4", "svc-project")
cfg := &config.ControlAPI{
Orchestrator: config.OrchestratorConfig{
PollIntervalSeconds: 1, SelfCheckTimeoutSeconds: 10, MaxSelfCheckRetries: 3,
CheckingWindowSeconds: 120, MaxRetries: 3, LeaseTTLSeconds: 180, HeartbeatTimeoutSeconds: 30,
},
Aggregation: config.AggregationConfig{MissingCountsAsFail: true},
Sites: []config.SiteConfig{
{SiteID: "site-1", Index: 1}, {SiteID: "site-2", Index: 2}, {SiteID: "site-3", Index: 3},
},
CheckTypes: []config.CheckTypeConfig{{Name: "https", Enabled: true, Targets: []string{"web"}}},
Targets: map[string][]string{"web": {"https://example.test"}},
Inbound: config.InboundConfig{Ports: []int{22, 80}, ICMP: true},
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
orch := orchestrator.New(d, mock, cfg, log)
if err := d.SeedQueue(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("seed queue: %v", err)
}
srv := New(d, orch, log)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
fc := &fakeClient{t: t, base: ts.URL, client: ts.Client()}
// Register the validator directly via DB (os_port_id comes from
// control-api config, not the agent's own registration call).
if err := d.RegisterValidator(ctx, "validator-1", "host-1", "port-1", "v0.1"); err != nil {
t.Fatalf("register validator: %v", err)
}
// Agent re-registers over HTTP (as the real binary would at startup).
resp, body := fc.do(http.MethodPost, "/api/v1/agents/register", registerAgentRequest{
ValidatorID: "validator-1", Hostname: "host-1", AgentVersion: "v0.1",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("register: status=%d body=%s", resp.StatusCode, body)
}
// Orchestrator claims the IP and associates the FIP.
orch.Tick(ctx)
resp, body = fc.do(http.MethodGet, "/api/v1/agents/validator-1/assignment", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("assignment: status=%d body=%s", resp.StatusCode, body)
}
var assignment assignmentResponse
if err := json.Unmarshal(body, &assignment); err != nil {
t.Fatalf("unmarshal assignment: %v", err)
}
if assignment.Phase != db.IPAwaitingSelfCheck {
t.Fatalf("expected awaiting_self_check, got %s", assignment.Phase)
}
if assignment.IPAddress != "1.2.3.4" {
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)
}
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",
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("self-check: status=%d body=%s", resp.StatusCode, body)
}
// Agent runs its configured egress check and reports the result.
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/results", agentResultsRequest{
Results: []checkResultDTO{{
IPID: assignment.IPID, CheckType: "https", Target: "https://example.test",
Success: true, LatencyMS: 12, CheckedAt: time.Now().Format(time.RFC3339Nano),
}},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("results: status=%d body=%s", resp.StatusCode, body)
}
resp, body = fc.do(http.MethodPost, "/api/v1/agents/validator-1/complete", agentCompleteRequest{IPID: assignment.IPID})
if resp.StatusCode != http.StatusOK {
t.Fatalf("complete: status=%d body=%s", resp.StatusCode, body)
}
// Three probers register, poll, and report inbound results.
for _, site := range []string{"site-1", "site-2", "site-3"} {
resp, body = fc.do(http.MethodPost, "/api/v1/probers/register", registerProberRequest{SiteID: site})
if resp.StatusCode != http.StatusOK {
t.Fatalf("prober register %s: status=%d body=%s", site, resp.StatusCode, body)
}
resp, body = fc.do(http.MethodGet, "/api/v1/probers/"+site+"/assignments", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("prober assignments %s: status=%d body=%s", site, resp.StatusCode, body)
}
var assignments []proberAssignment
if err := json.Unmarshal(body, &assignments); err != nil {
t.Fatalf("unmarshal assignments: %v", err)
}
if len(assignments) != 1 || assignments[0].IPAddress != "1.2.3.4" {
t.Fatalf("expected 1 assignment for 1.2.3.4, got %+v", assignments)
}
now := time.Now().Format(time.RFC3339Nano)
resp, body = fc.do(http.MethodPost, "/api/v1/probers/"+site+"/results", proberResultsRequest{
Results: []proberResultDTO{
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "tcp-22", Success: true, CheckedAt: now},
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "tcp-80", Success: true, CheckedAt: now},
{IPID: assignments[0].IPID, IPAddress: "1.2.3.4", CheckType: "icmp", Success: true, CheckedAt: now, Complete: true},
},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("prober results %s: status=%d body=%s", site, resp.StatusCode, body)
}
}
// Orchestrator sweep should now aggregate and release.
orch.Tick(ctx)
resp, body = fc.do(http.MethodGet, "/api/v1/admin/ips/1.2.3.4", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("admin ip detail: status=%d body=%s", resp.StatusCode, body)
}
var detail struct {
IP db.IPQueueItem `json:"ip"`
}
if err := json.Unmarshal(body, &detail); err != nil {
t.Fatalf("unmarshal detail: %v", err)
}
if detail.IP.State != db.IPDone {
t.Fatalf("expected done, got %s", detail.IP.State)
}
if detail.IP.OverallResult != db.ResultPass {
t.Fatalf("expected pass, got %s", detail.IP.OverallResult)
}
if fip, _ := mock.GetFloatingIPByAddress(ctx, "1.2.3.4"); fip.PortID != "" {
t.Fatalf("expected fip disassociated at end of run")
}
}
+25
View File
@@ -0,0 +1,25 @@
package httpapi
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)
mux.HandleFunc("GET /api/v1/agents/{id}/assignment", s.handleAgentAssignment)
mux.HandleFunc("POST /api/v1/agents/{id}/self-check", s.handleAgentSelfCheck)
mux.HandleFunc("POST /api/v1/agents/{id}/events", s.handleAgentEvent)
mux.HandleFunc("POST /api/v1/agents/{id}/results", s.handleAgentResults)
mux.HandleFunc("POST /api/v1/agents/{id}/complete", s.handleAgentComplete)
mux.HandleFunc("POST /api/v1/probers/register", s.handleProberRegister)
mux.HandleFunc("GET /api/v1/probers/{site_id}/assignments", s.handleProberAssignments)
mux.HandleFunc("POST /api/v1/probers/{site_id}/results", s.handleProberResults)
mux.HandleFunc("GET /api/v1/admin/status", s.handleAdminStatus)
mux.HandleFunc("GET /api/v1/admin/ips", s.handleAdminIPs)
mux.HandleFunc("GET /api/v1/admin/ips/{ip}", s.handleAdminIPDetail)
mux.HandleFunc("GET /api/v1/admin/validators", s.handleAdminValidators)
}
+71
View File
@@ -0,0 +1,71 @@
// Package httpapi exposes the Control API's HTTP surface — the only way
// validator-agents, probers, and operators interact with the system. All
// business logic lives in internal/orchestrator; handlers here do request
// parsing/validation, call into the orchestrator or db package, and shape
// the JSON response.
package httpapi
import (
"encoding/json"
"log/slog"
"net"
"net/http"
"cloudipvalidator/internal/db"
"cloudipvalidator/internal/orchestrator"
)
type Server struct {
DB *db.DB
Orch *orchestrator.Orchestrator
Log *slog.Logger
}
func New(d *db.DB, o *orchestrator.Orchestrator, log *slog.Logger) *Server {
return &Server{DB: d, Orch: o, Log: log}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.routes(mux)
return loggingMiddleware(s.Log, mux)
}
func loggingMiddleware(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
log.Debug("request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
})
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorResponse{Error: msg})
}
func readJSON(r *http.Request, v interface{}) error {
if r.Body == nil || r.ContentLength == 0 {
return nil
}
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
}