Files
sultan/pkg/server/handlers.go
T
2026-10-01 23:52:15 +02:00

163 lines
5.0 KiB
Go

package server
import (
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"sultan/pkg/auth"
"sultan/pkg/policy"
"sultan/pkg/registry"
"sultan/pkg/store"
)
// EnrollRequest ist der Body von POST /api/v1/enroll. Der Request ist mit
// dem neuen Schlüssel des Agenten signiert (Besitznachweis).
type EnrollRequest struct {
Token string `json:"token"` // sltn_enroll_<secret>, ohne Fingerprint
NodeID string `json:"node_id"`
Hostname string `json:"hostname"`
}
// EnrollResponse ist die Antwort auf ein erfolgreiches Enrollment.
type EnrollResponse struct {
NodeID string `json:"node_id"`
TenantID string `json:"tenant_id"`
MasterPublicKey string `json:"master_public_key"` // zum Prüfen von Lizenzen/Leases
}
func (s *Server) agentRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/enroll", s.handleEnroll)
mux.HandleFunc("GET /api/v1/stream", s.handleStream)
mux.HandleFunc("POST /api/v1/state", s.handleState)
return mux
}
func (s *Server) providerRoutes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /v1/traefik-config", s.registry.Handler())
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("ok\n"))
})
return mux
}
func (s *Server) handleEnroll(w http.ResponseWriter, r *http.Request) {
pub, err := s.verifier.VerifyUnregistered(r)
if err != nil {
authError(w, err)
return
}
var req EnrollRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
if !policy.ValidID(req.NodeID) || strings.TrimSpace(req.Hostname) == "" {
http.Error(w, "node_id ungültig oder hostname fehlt", http.StatusBadRequest)
return
}
node, err := s.store.Enroll(r.Context(), req.Token, store.Node{
ID: req.NodeID,
Hostname: req.Hostname,
PublicKey: auth.EncodePublicKey(pub),
IPAddress: remoteIP(r),
Status: store.NodeOffline, // online erst mit offenem Stream
})
switch {
case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrTokenUsed), errors.Is(err, store.ErrTokenExpired):
// Keine Details nach außen, ob Token unbekannt, verbraucht oder abgelaufen.
s.log.Warn("Enrollment abgelehnt", "node", req.NodeID, "ip", remoteIP(r), "err", err)
http.Error(w, "Einladung ungültig", http.StatusForbidden)
return
case errors.Is(err, store.ErrExists):
http.Error(w, "Knoten-ID oder Schlüssel bereits registriert", http.StatusConflict)
return
case errors.Is(err, store.ErrInvalid):
http.Error(w, err.Error(), http.StatusBadRequest)
return
case err != nil:
s.log.Error("Enrollment", "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
s.log.Info("Knoten registriert", "node", node.ID, "tenant", node.TenantID, "ip", node.IPAddress)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(EnrollResponse{
NodeID: node.ID,
TenantID: node.TenantID,
MasterPublicKey: auth.EncodePublicKey(s.MasterPublicKey()),
})
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
if err := s.store.TouchNode(r.Context(), id.Node.ID, remoteIP(r)); err != nil {
s.log.Error("Lebenszeichen speichern", "node", id.Node.ID, "err", err)
}
err = s.hub.Serve(w, r, id.Node.ID)
s.log.Debug("Stream beendet", "node", id.Node.ID, "reason", err)
}
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
// Lebendigkeit hängt am Stream: Ohne offenen Stream würden die Routen
// beim nächsten Trennen nie wieder entfernt.
if !s.hub.Connected(id.Node.ID) {
http.Error(w, "Stream nicht verbunden", http.StatusConflict)
return
}
var state registry.NodeState
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
err = s.registry.Update(r.Context(), id.Node.ID, state)
switch {
case errors.Is(err, registry.ErrUnknownNode):
http.Error(w, "Knoten unbekannt", http.StatusUnauthorized)
return
case errors.Is(err, policy.ErrInvalidInput):
s.log.Error("Zustand abgelehnt", "node", id.Node.ID, "err", err)
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
case err != nil:
s.log.Error("Zustand übernehmen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
if err := s.store.TouchNode(r.Context(), id.Node.ID, remoteIP(r)); err != nil {
s.log.Error("Lebenszeichen speichern", "node", id.Node.ID, "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
func authError(w http.ResponseWriter, err error) {
code := auth.StatusCode(err)
msg := http.StatusText(code)
if code == http.StatusUpgradeRequired {
msg = "nur über TLS"
}
http.Error(w, msg, code)
}
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}