Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
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>
2026-09-03 13:57:15 -04:00

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