2026-08-21 07:34:45 +03:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-02 03:24:20 +03:00
|
|
|
"database/sql"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net"
|
2026-08-21 07:34:45 +03:00
|
|
|
"net/http"
|
2026-10-02 03:24:20 +03:00
|
|
|
"net/netip"
|
2026-08-21 07:34:45 +03:00
|
|
|
"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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-02 03:24:20 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 07:34:45 +03:00
|
|
|
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})
|
|
|
|
|
}
|