163 lines
5.0 KiB
Go
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
|
|
}
|