Files
sultan/pkg/server/handlers.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

281 lines
9.2 KiB
Go

package server
import (
"encoding/json"
"errors"
"math"
"net"
"net/http"
"strconv"
"strings"
"time"
"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
}
// MaintenanceRequest ist der Body von POST /api/v1/maintenance: Der Knoten
// meldet Wartung an oder ab (z. B. aus einem Patch-/Reboot-Skript).
type MaintenanceRequest struct {
Enabled bool `json:"enabled"`
Duration string `json:"duration,omitempty"` // Go-Dauer; "" = 4h, "0" = unbefristet
Reason string `json:"reason,omitempty"` // intern
PublicReason string `json:"public_reason,omitempty"`
}
// RotateKeyRequest ist der Body von POST /api/v1/rotate-key. Der Request ist
// mit dem bisherigen Schlüssel signiert; Proof (auth.RotationProof) beweist
// den Besitz des neuen.
type RotateKeyRequest struct {
PublicKey string `json:"public_key"`
Proof string `json:"proof"`
}
// DefaultMaintenance ist die Wartungsdauer ohne Angabe.
const DefaultMaintenance = 4 * time.Hour
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)
mux.HandleFunc("POST /api/v1/maintenance", s.handleMaintenance)
mux.HandleFunc("POST /api/v1/rotate-key", s.handleRotateKey)
return mux
}
func (s *Server) providerRoutes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /v1/traefik-config", s.registry.Handler())
mux.HandleFunc("GET /metrics/checkmk", s.handleCheckmk)
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) {
// Vor der Signaturprüfung: Fluten kosten so kaum etwas.
if ok, retry := s.enrollRL.allow(remoteIP(r)); !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds()))))
s.log.Warn("Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r))
http.Error(w, "zu viele Versuche", http.StatusTooManyRequests)
return
}
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 (s *Server) handleMaintenance(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
var req MaintenanceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
if !req.Enabled {
err = s.store.ClearNodeMaintenance(r.Context(), id.Node.ID)
} else {
d := DefaultMaintenance
if req.Duration != "" {
if d, err = time.ParseDuration(req.Duration); err != nil || d < 0 {
http.Error(w, "duration ungültig (z. B. 30m, 4h; 0 = unbefristet)", http.StatusBadRequest)
return
}
}
m := store.Maintenance{Reason: req.Reason, PublicReason: req.PublicReason}
if d > 0 {
m.Until = time.Now().Add(d)
}
err = s.store.SetNodeMaintenance(r.Context(), id.Node.ID, m)
}
switch {
case errors.Is(err, store.ErrInvalid):
http.Error(w, err.Error(), http.StatusBadRequest)
return
case err != nil:
s.log.Error("Wartung setzen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
if req.Enabled {
s.log.Info("Wartung angemeldet", "node", id.Node.ID, "duration", req.Duration, "reason", req.Reason,
"public_reason", req.PublicReason)
} else {
s.log.Info("Wartung beendet", "node", id.Node.ID)
}
// Sofort wirksam, nicht erst beim nächsten Refresh.
if err := s.registry.Refresh(r.Context()); err != nil {
s.log.Error("Neubewertung nach Wartungsänderung", "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
// handleRotateKey tauscht den Schlüssel eines Knotens. Idempotent: Ist der
// neue Schlüssel schon aktiv (Wiederholung nach einem Abbruch, signiert mit
// dem neuen Schlüssel), antwortet er ebenfalls mit 204.
func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) {
id, err := s.verifier.Verify(r)
if err != nil {
authError(w, err)
return
}
var req RotateKeyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger Body", http.StatusBadRequest)
return
}
pub, err := auth.DecodePublicKey(req.PublicKey)
if err != nil || !auth.VerifyRotationProof(id.Node.ID, pub, req.Proof) {
http.Error(w, "Besitznachweis für den neuen Schlüssel ungültig", http.StatusBadRequest)
return
}
encoded := auth.EncodePublicKey(pub)
if encoded != id.Node.PublicKey {
err := s.store.UpdateNodePublicKey(r.Context(), id.Node.ID, encoded)
switch {
case errors.Is(err, store.ErrExists):
http.Error(w, "Schlüssel bereits vergeben", http.StatusConflict)
return
case err != nil:
s.log.Error("Schlüssel tauschen", "node", id.Node.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
s.log.Info("Knotenschlüssel gewechselt", "node", id.Node.ID)
}
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
}