// Package server provides the HTTP server and API routing package server import ( "context" "fmt" "net/http" "time" "github.com/Grace-Solutions/OrchestrAD/internal/api" "github.com/Grace-Solutions/OrchestrAD/internal/audit" "github.com/Grace-Solutions/OrchestrAD/internal/auth" "github.com/Grace-Solutions/OrchestrAD/internal/config" "github.com/Grace-Solutions/OrchestrAD/internal/db" "github.com/Grace-Solutions/OrchestrAD/internal/logging" "github.com/Grace-Solutions/OrchestrAD/internal/repository" "github.com/Grace-Solutions/OrchestrAD/internal/rules/engine" "github.com/Grace-Solutions/OrchestrAD/internal/rules/runner" "github.com/Grace-Solutions/OrchestrAD/internal/services" "github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr" "github.com/Grace-Solutions/OrchestrAD/internal/version" "github.com/Grace-Solutions/OrchestrAD/internal/webui" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" ) // Dependencies bundles the services the server needs to satisfy its routes. type Dependencies struct { Runner *runner.Runner Engine *engine.Engine AuthService *auth.Service ConnService *services.ConnectionService RuleService *services.RuleService CredService *services.CredentialService APIKeyService *services.APIKeyService BackupService *services.BackupService SettingsService *services.SettingsService DashboardService *services.DashboardService ActivityService *services.ActivityService ConfigService *services.ConfigService AuditService *audit.Service RuleRepo *repository.RuleRepository ConnRepo *repository.ConnectionRepository ScheduleRepo *repository.ScheduleRepository RunRepo *repository.RuleRunRepository UserRepo *repository.UserRepository // TLS, when non-nil, makes the server listen over HTTPS using the managed // certificate. Nil serves plain HTTP (e.g. behind a TLS-terminating proxy). TLS *tlsmgr.Manager } // Server represents the HTTP server type Server struct { config *config.Config db *db.DB logger *logging.Logger deps Dependencies router *chi.Mux httpSrv *http.Server } // New creates a new Server instance func New(cfg *config.Config, database *db.DB, deps Dependencies, logger *logging.Logger) *Server { s := &Server{ config: cfg, db: database, logger: logger, deps: deps, router: chi.NewRouter(), } s.setupMiddleware() s.setupRoutes() return s } func (s *Server) setupMiddleware() { // Request ID s.router.Use(middleware.RequestID) // Rewrite scheme/host/remote-addr from X-Forwarded-* headers, but only when // the immediate peer is inside one of the configured trusted CIDR ranges. // With no trusted proxies configured the middleware is a no-op, so direct // exposure remains safe by default. tp, err := parseTrustedProxies(s.config.Server.TrustedProxies) if err != nil { s.logger.Warn("HTTP", "invalid ORCHESTRAD_TRUSTED_PROXIES entry, proxy headers disabled: %v", err) tp = nil } s.router.Use(proxyHeadersMiddleware(tp)) // Request logging s.router.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) next.ServeHTTP(ww, r) s.logger.Info("HTTP", "%s %s %d %s", r.Method, r.URL.Path, ww.Status(), time.Since(start)) }) }) // Panic recovery s.router.Use(middleware.Recoverer) // CORS s.router.Use(cors.Handler(cors.Options{ AllowedOrigins: s.config.CORS.AllowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, ExposedHeaders: []string{"Link"}, AllowCredentials: s.config.CORS.AllowCredentials, MaxAge: 300, })) } func (s *Server) setupRoutes() { // Health check (unauthenticated). Register HEAD as well as GET: container // health probes and reverse proxies commonly issue HEAD (e.g. the Docker // HEALTHCHECK's `wget --spider`), and chi returns 405 for an unregistered // method rather than falling back to the GET handler. s.router.Get("/health", s.handleHealth) s.router.Head("/health", s.handleHealth) s.router.Get("/api/health", s.handleHealth) s.router.Head("/api/health", s.handleHealth) // API documentation: an OpenAPI 3 spec generated from this router (so it // stays in sync), a compact route list, and a Swagger UI. All require // authentication. The machine endpoints take a bearer token / API key; the // docs page (and the spec copy under it) also accept the path-scoped // session cookie set at login, so a signed-in operator can open it directly // and an anonymous browser is bounced to the login page. openAPIHandler := api.NewOpenAPIHandler(s.router) s.router.With(api.AuthMiddleware(s.deps.AuthService)).Get("/api/openapi.json", openAPIHandler.Spec) s.router.With(api.AuthMiddleware(s.deps.AuthService)).Get("/api/routes", openAPIHandler.Routes) s.router.With(api.DocsPageAuthMiddleware(s.deps.AuthService)).Get("/api/docs", openAPIHandler.UI) s.router.With(api.AuthMiddleware(s.deps.AuthService)).Get("/api/docs/openapi.json", openAPIHandler.Spec) // CSRF guards cookie-authenticated mutations. Bearer/API-key requests are // not CSRF-reachable and pass straight through, so this is transparent to // the SPA and to API clients. csrf := api.NewCSRF(s.config.SecretKey) // API v1 routes s.router.Route("/api/v1", func(r chi.Router) { r.Use(csrf.Middleware) // Public system endpoints — discoverable without a session so that // liveness probes, reverse proxies, and the bootstrap flow can // interrogate the server before any user has signed in. r.Get("/version", s.handleVersion) r.Get("/health", s.handleHealth) r.Head("/health", s.handleHealth) // Auth endpoints. Login / logout / csrf are public by design; /me // requires a valid session so clients can resolve the current user. authHandler := api.NewAuthHandler(s.deps.AuthService, s.deps.AuditService, s.logger) oidcHandler := api.NewOIDCHandler(s.deps.AuthService, s.deps.SettingsService, s.deps.AuditService, s.logger) r.Route("/auth", func(r chi.Router) { r.Post("/login", authHandler.Login) r.Post("/logout", authHandler.Logout) r.With(api.AuthMiddleware(s.deps.AuthService)).Get("/me", authHandler.Me) r.With(api.AuthMiddleware(s.deps.AuthService)).Post("/change-password", authHandler.ChangePassword) r.Get("/csrf", csrf.Handler) // SSO (OIDC). status/login/callback are public; config is admin-only. r.Get("/oidc/status", oidcHandler.Status) r.Get("/oidc/login", oidcHandler.Login) r.Get("/oidc/callback", oidcHandler.Callback) r.With(api.AuthMiddleware(s.deps.AuthService)).Get("/oidc/config", oidcHandler.GetConfig) r.With(api.AuthMiddleware(s.deps.AuthService)).Put("/oidc/config", oidcHandler.PutConfig) }) // Everything below this group requires a valid session token. API // keys currently piggyback on the same bearer token header; a future // middleware can add API-key-based auth for external scripts. r.Group(func(r chi.Router) { r.Use(api.AuthMiddleware(s.deps.AuthService)) // Users usersHandler := api.NewUsersHandler(s.deps.UserRepo, s.deps.AuditService, s.logger) r.Route("/users", func(r chi.Router) { r.Get("/", usersHandler.List) r.Post("/", usersHandler.Create) r.Get("/{id}", usersHandler.Get) r.Put("/{id}", usersHandler.Update) r.Delete("/{id}", usersHandler.Delete) }) // Assignable roles (built-in RBAC roles) r.Get("/roles", usersHandler.ListRoles) // Credentials credentialsHandler := api.NewCredentialsHandler(s.deps.CredService, s.deps.AuditService, s.logger) r.Route("/credentials", func(r chi.Router) { r.Get("/", credentialsHandler.List) r.Post("/", credentialsHandler.Create) r.Get("/{id}", credentialsHandler.Get) r.Put("/{id}", credentialsHandler.Update) r.Delete("/{id}", credentialsHandler.Delete) r.Post("/{id}/test", credentialsHandler.Test) r.Post("/{id}/enable", credentialsHandler.Enable) r.Post("/{id}/disable", credentialsHandler.Disable) }) // AD Connections connectionsHandler := api.NewConnectionsHandler(s.deps.ConnRepo, s.deps.ConnService, s.deps.AuditService, s.logger) r.Route("/ad-connections", func(r chi.Router) { r.Get("/", connectionsHandler.List) r.Post("/", connectionsHandler.Create) r.Get("/{id}", connectionsHandler.Get) r.Put("/{id}", connectionsHandler.Update) r.Delete("/{id}", connectionsHandler.Delete) r.Post("/{id}/test", connectionsHandler.Test) r.Post("/{id}/query-preview", connectionsHandler.QueryPreview) r.Get("/{id}/directory", connectionsHandler.DirectorySearch) r.Get("/{id}/attributes", connectionsHandler.Attributes) r.Get("/{id}/attribute-values", connectionsHandler.AttributeValues) r.Post("/{id}/enable", connectionsHandler.Enable) r.Post("/{id}/disable", connectionsHandler.Disable) }) // Schedules schedulesHandler := api.NewSchedulesHandler(s.deps.ScheduleRepo, s.deps.AuditService, s.logger) r.Route("/schedules", func(r chi.Router) { r.Get("/", schedulesHandler.List) r.Post("/", schedulesHandler.Create) r.Get("/{id}", schedulesHandler.Get) r.Put("/{id}", schedulesHandler.Update) r.Delete("/{id}", schedulesHandler.Delete) r.Post("/{id}/enable", schedulesHandler.Enable) r.Post("/{id}/disable", schedulesHandler.Disable) }) // Rules rulesHandler := api.NewRulesHandler(s.deps.Runner, s.deps.RuleService, s.deps.AuditService, s.logger) ruleRunsHandler := api.NewRuleRunsHandler(s.deps.RunRepo, s.logger) r.Route("/rules", func(r chi.Router) { r.Get("/", rulesHandler.List) r.Post("/", rulesHandler.Create) r.Get("/metadata", rulesHandler.Metadata) r.Post("/preview", rulesHandler.PreviewSpec) r.Get("/{id}", rulesHandler.Get) r.Put("/{id}", rulesHandler.Update) r.Delete("/{id}", rulesHandler.Delete) r.Post("/{id}/preview", rulesHandler.Preview) r.Post("/{id}/run", rulesHandler.Run) r.Post("/{id}/enable", rulesHandler.Enable) r.Post("/{id}/disable", rulesHandler.Disable) r.Get("/{id}/runs", ruleRunsHandler.ListByRule) }) // Rule runs (execution history across all rules) r.Route("/rule-runs", func(r chi.Router) { r.Get("/", ruleRunsHandler.List) r.Get("/{runId}", ruleRunsHandler.Get) }) // Backups backupsHandler := api.NewBackupsHandler(s.deps.BackupService, s.deps.AuditService, s.logger) r.Route("/backups", func(r chi.Router) { r.Get("/", backupsHandler.List) r.Post("/", backupsHandler.Create) r.Post("/{id}/restore", backupsHandler.Restore) // A restore is staged, then applied on the next start; these // let an operator see and cancel one before it takes effect. r.Get("/restore", backupsHandler.RestoreStatus) r.Delete("/restore", backupsHandler.CancelRestore) }) // API Keys — protected by the enclosing group's AuthMiddleware. apiKeysHandler := api.NewAPIKeysHandler(s.deps.APIKeyService, s.deps.AuditService, s.logger) r.Route("/api-keys", func(r chi.Router) { r.Get("/", apiKeysHandler.List) r.Post("/", apiKeysHandler.Create) r.Post("/{id}/revoke", apiKeysHandler.Revoke) r.Post("/{id}/enable", apiKeysHandler.Enable) r.Post("/{id}/disable", apiKeysHandler.Disable) r.Delete("/{id}", apiKeysHandler.Delete) }) // Audit log (read-only) auditHandler := api.NewAuditHandler(s.deps.AuditService, s.logger) r.Route("/audit", func(r chi.Router) { r.Get("/", auditHandler.List) r.Get("/{id}", auditHandler.Get) }) // Application settings settingsHandler := api.NewSettingsHandler(s.deps.SettingsService, s.deps.AuditService, s.logger) r.Route("/settings", func(r chi.Router) { r.Get("/", settingsHandler.List) r.Get("/{key}", settingsHandler.Get) r.Put("/{key}", settingsHandler.Upsert) r.Delete("/{key}", settingsHandler.Delete) }) // Dashboard aggregation dashboardHandler := api.NewDashboardHandler(s.deps.DashboardService, s.logger) r.Route("/dashboard", func(r chi.Router) { r.Get("/summary", dashboardHandler.Summary) }) // Activity intelligence (action ledger roll-ups + drill-in feed) activityHandler := api.NewActivityHandler(s.deps.ActivityService, s.logger) r.Route("/activity", func(r chi.Router) { r.Get("/", activityHandler.List) r.Get("/summary", activityHandler.Summary) }) // Configuration export / import configHandler := api.NewConfigHandler(s.deps.ConfigService, s.deps.AuditService, s.logger) r.Route("/config", func(r chi.Router) { r.Get("/export", configHandler.Export) r.Post("/import", configHandler.Import) }) // TLS certificate configuration (mode, bring-your-own upload, // Windows-store selection). tlsHandler := api.NewTLSHandler(s.deps.SettingsService, s.deps.TLS, s.deps.AuditService, s.logger) r.Route("/tls", func(r chi.Router) { r.Get("/status", tlsHandler.Status) r.Post("/mode", tlsHandler.SetMode) r.Post("/certificate", tlsHandler.UploadCertificate) r.Get("/windows-store", tlsHandler.WindowsCerts) }) }) // end protected group }) // Embedded web UI. Serves the Next.js static export for all non-API, // non-health routes with SPA fallback so client-side deep links work. uiHandler := webui.Handler() s.router.NotFound(uiHandler.ServeHTTP) if !webui.IsBuilt() { s.logger.Warn("Server", "Embedded UI is a placeholder; build the frontend and rebuild the backend to ship the real UI") } } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"healthy"}`)) } func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `{"version":"%s","build_time":"%s","git_commit":"%s"}`, version.Version, version.BuildTime, version.GitCommit) } func (s *Server) handleNotImplemented(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotImplemented) w.Write([]byte(`{"error":"Not implemented yet"}`)) } // Run starts the HTTP server and blocks until shutdown func (s *Server) Run(ctx context.Context) error { addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port) useTLS := s.deps.TLS != nil s.httpSrv = &http.Server{ Addr: addr, Handler: s.router, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } if useTLS { s.httpSrv.TLSConfig = s.deps.TLS.TLSConfig() } scheme, schemeLower := "HTTP", "http" if useTLS { scheme, schemeLower = "HTTPS", "https" } s.logger.Info("Server", "Starting %s server on %s", scheme, addr) // Log a clickable URL. 0.0.0.0/:: are not directly reachable, so show // localhost for those. accessHost := s.config.Server.Host if accessHost == "0.0.0.0" || accessHost == "::" || accessHost == "" { accessHost = "localhost" } s.logger.Info("Server", "OrchestrAD is available at %s://%s:%d/", schemeLower, accessHost, s.config.Server.Port) errChan := make(chan error, 1) go func() { var err error if useTLS { // Certificates come from the manager's GetCertificate, so the file // arguments are intentionally empty. err = s.httpSrv.ListenAndServeTLS("", "") } else { err = s.httpSrv.ListenAndServe() } if err != nil && err != http.ErrServerClosed { errChan <- err } }() select { case <-ctx.Done(): s.logger.Info("Server", "Shutting down HTTP server...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() return s.httpSrv.Shutdown(shutdownCtx) case err := <-errChan: return err } }