63701dd086
Addresses the gaps identified in the last audit. Restore (was a stub returning "not yet implemented"). Every repository shares one connection pool, so the database cannot be swapped underneath a live server. Restore is therefore two-phase: RestoreBackup validates the file and stages it beside the database; db.New applies it before the pool is opened, which is the only safe moment. The database being replaced is preserved as <db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot replay the old journal over the restored file. Validation is strict — SQLite integrity_check plus a schema probe — because applying an unrelated file would destroy the install. GET/DELETE /api/v1/backups/restore inspect and cancel a staged restore. The CLI does both phases at once, since it runs standalone; `orchestrad backup` was also a stub and now works. Secret key. With nothing configured the key is generated once and persisted to <data>/secret.key, so restarts reuse it and moving the stack to another server is a matter of copying the data directory. Upgrades are handled: if a database already exists the install was silently running on the legacy built-in default, so that value is adopted and written out rather than replaced — generating a fresh key there would make every stored credential undecryptable. The file is owner-only (ACL-restricted on Windows). Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the architectures the release binaries already covered. The Dockerfile cross-compiles via TARGETARCH rather than emulating, so arm64 costs little. CSRF: the middleware previously checked only that a header was *present* and was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens are now nonce + HMAC-SHA256 signed with the application secret, validated properly, and the middleware is mounted on /api/v1. Bearer and API-key requests are not CSRF-reachable and pass through untouched, so this is transparent to the SPA and to API clients. Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not the source of truth — <data>/tls holds the key, so portability is unaffected and a non-exportable server key is the better posture), the PFX password is written to server.pfx.password beside the bundle so an operator importing it by hand does not have to hunt for a password they never chose, and the "renewed" log line now reflects whether a leaf was actually issued instead of guessing from its age. Verified live: backup -> stage -> restart applies and preserves the previous database; secret key generated, adopted, and read back across restarts with the credential check confirming decryptability; CSRF endpoint issues real signed tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
411 lines
16 KiB
Go
411 lines
16 KiB
Go
// 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
|
|
}
|
|
}
|