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_, 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 }