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