package api import ( "context" "log/slog" "sync" "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/docker-watcher/internal/backup" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/docker-watcher/internal/stale" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/webhook" ) // DNSProviderChangedFunc is called when DNS settings change so the caller can // update the provider on the deployer and proxy manager. type DNSProviderChangedFunc func(provider dns.Provider) // Server holds all dependencies for the API layer. type Server struct { store *store.Store docker *docker.Client npm *npm.Client // optional: only for NPM-specific endpoints (certificates) proxyProvider proxy.Provider deployer DeployTriggerer webhook *webhook.Handler eventBus *events.Bus encKey [32]byte localAuth *auth.LocalAuth oidcProvider *auth.OIDCProvider staleScanner *stale.Scanner proxyManager *proxy.Manager dnsProviderMu sync.RWMutex dnsProvider dns.Provider onDNSProviderChanged DNSProviderChangedFunc backupEngine *backup.Engine dbPath string shutdownFunc func() // called after restore to trigger graceful shutdown onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change } // NewServer creates a new API Server with all required dependencies. func NewServer( st *store.Store, dockerClient *docker.Client, npmClient *npm.Client, proxyProvider proxy.Provider, deployer DeployTriggerer, webhookHandler *webhook.Handler, eventBus *events.Bus, encKey [32]byte, ) *Server { localAuth := auth.NewLocalAuth(encKey) s := &Server{ store: st, docker: dockerClient, npm: npmClient, proxyProvider: proxyProvider, deployer: deployer, webhook: webhookHandler, eventBus: eventBus, encKey: encKey, localAuth: localAuth, } // Try to initialize OIDC provider from stored settings. authSettings, err := st.GetAuthSettings() if err == nil && authSettings.AuthMode == "oidc" && authSettings.OIDCIssuerURL != "" { s.initOIDCProvider(context.Background(), authSettings) } return s } // SetStaleScanner sets the stale scanner on the server. // Called after both the API server and scanner are initialized. func (s *Server) SetStaleScanner(scanner *stale.Scanner) { s.staleScanner = scanner } // SetProxyManager sets the proxy manager on the server. // Called after both the API server and proxy manager are initialized. func (s *Server) SetProxyManager(pm *proxy.Manager) { s.proxyManager = pm } // SetBackupEngine sets the backup engine on the server. func (s *Server) SetBackupEngine(engine *backup.Engine) { s.backupEngine = engine } // SetDBPath sets the database file path (needed for restore). func (s *Server) SetDBPath(path string) { s.dbPath = path } // SetShutdownFunc sets the function called after a restore to trigger graceful shutdown. func (s *Server) SetShutdownFunc(fn func()) { s.shutdownFunc = fn } // SetBackupSettingsChangedCallback sets the callback for when backup settings change. func (s *Server) SetBackupSettingsChangedCallback(fn func(enabled bool, intervalHours int)) { s.onBackupSettingsChanged = fn } // SetDNSProvider sets the current DNS provider on the server. func (s *Server) SetDNSProvider(provider dns.Provider) { s.dnsProviderMu.Lock() defer s.dnsProviderMu.Unlock() s.dnsProvider = provider } // getDNSProviderLocked returns the current DNS provider under read lock. func (s *Server) getDNSProviderLocked() dns.Provider { s.dnsProviderMu.RLock() defer s.dnsProviderMu.RUnlock() return s.dnsProvider } // SetDNSProviderChangedCallback sets the callback for when DNS settings change. func (s *Server) SetDNSProviderChangedCallback(fn DNSProviderChangedFunc) { s.onDNSProviderChanged = fn } // initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal. func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) { // Decrypt the OIDC client secret if it's encrypted. clientSecret := as.OIDCClientSecret if clientSecret != "" { if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil { clientSecret = decrypted } // If decrypt fails, assume it's already plaintext (migration scenario). } provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{ IssuerURL: as.OIDCIssuerURL, ClientID: as.OIDCClientID, ClientSecret: clientSecret, RedirectURL: as.OIDCRedirectURL, }) if err != nil { slog.Warn("failed to initialize OIDC provider", "error", err) return } s.oidcProvider = provider slog.Info("OIDC provider initialized", "issuer", as.OIDCIssuerURL) } // Router returns a chi router with all API routes mounted. func (s *Server) Router() chi.Router { r := chi.NewRouter() // Global middleware. r.Use(recovery) r.Use(securityHeaders) r.Use(logging) r.Use(cors) loginLimiter := newRateLimiter() r.Route("/api", func(r chi.Router) { // JSON content type and body size limit for API routes. r.Use(jsonContentType) r.Use(limitBody) // Public auth endpoints (no auth required). r.Post("/auth/login", s.rateLimitedLogin(loginLimiter)) r.Get("/auth/oidc/login", s.oidcLogin) r.Get("/auth/oidc/callback", s.oidcCallback) r.Post("/auth/oidc/token", s.oidcExchangeToken) // Webhook handler (uses its own secret-based auth). r.Mount("/webhook", s.webhook.Route()) // Protected routes: require valid JWT. r.Group(func(r chi.Router) { r.Use(auth.Middleware(s.localAuth)) // Read-only endpoints (any authenticated user). r.Get("/health", s.getHealth) r.Get("/auth/me", s.currentUser) r.Post("/auth/logout", s.logout) r.Get("/projects", s.listProjects) r.Route("/projects/{id}", func(r chi.Router) { r.Get("/", s.getProject) r.Get("/stages/{stage}/env", s.listStageEnv) r.Get("/stages/{stage}/instances", s.listInstances) r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats) r.Get("/volumes", s.listVolumes) r.Get("/volumes/{volId}/browse", s.browseVolume) r.Get("/volumes/{volId}/download", s.downloadVolume) // Admin-only project mutations. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateProject) r.Delete("/", s.deleteProject) // Stage endpoints. r.Post("/stages", s.createStage) r.Put("/stages/{stage}", s.updateStage) r.Delete("/stages/{stage}", s.deleteStage) // Stage env override endpoints. r.Post("/stages/{stage}/env", s.createStageEnv) r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv) r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv) // Instance endpoints. r.Post("/stages/{stage}/instances", s.deployInstance) r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance) // Instance control endpoints. r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance) r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance) r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance) // Volume endpoints. r.Post("/volumes", s.createVolume) r.Put("/volumes/{volId}", s.updateVolume) r.Delete("/volumes/{volId}", s.deleteVolume) r.Post("/volumes/{volId}/upload", s.uploadToVolume) }) }) r.Get("/deploys", s.listDeploys) r.Get("/deploys/{id}/logs", s.streamDeployLogs) r.Get("/events", s.streamEvents) r.Get("/events/log", s.listEventLog) r.Get("/events/log/stats", s.getEventLogStats) r.Get("/registries", s.listRegistries) r.Route("/registries/{id}", func(r chi.Router) { r.Get("/tags/*", s.listRegistryTags) r.Get("/images", s.listRegistryImages) // Admin-only registry mutations. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateRegistry) r.Delete("/", s.deleteRegistry) r.Post("/test", s.testRegistry) }) }) r.Get("/settings", s.getSettings) r.Get("/settings/npm-certificates", s.listNpmCertificates) // Volume scope metadata (read-only). r.Get("/volumes/scopes", s.listVolumeScopes) // Stale container endpoints (read). r.Get("/containers/stale", s.listStaleContainers) // Proxy endpoints (read-only for any authenticated user). r.Get("/proxies", s.listProxies) r.Get("/proxies/all", s.listAllProxies) r.Route("/proxies/{id}", func(r chi.Router) { r.Get("/", s.getProxy) // Admin-only proxy mutations. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateProxy) r.Delete("/", s.deleteProxy) }) }) // Admin-only routes: require admin role. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) // Config export (reveals project/infra details). r.Get("/config/export", s.exportConfig) // Auth management. r.Get("/auth/settings", s.getAuthSettings) r.Put("/auth/settings", s.updateAuthSettings) r.Get("/auth/users", s.listUsers) r.Post("/auth/users", s.createUser) r.Put("/auth/users/{uid}", s.updateUser) r.Put("/auth/users/{uid}/password", s.changePassword) r.Delete("/auth/users/{uid}", s.deleteUser) // Project creation. r.Post("/projects", s.createProject) // Quick deploy endpoints. r.Post("/deploy/inspect", s.inspectImage) r.Post("/deploy/quick", s.quickDeploy) // Registry creation. r.Post("/registries", s.createRegistry) // Proxy mutation endpoints. r.Post("/proxies/validate", s.validateProxy) r.Post("/proxies", s.createProxy) // Stale container cleanup endpoints. // Bulk route must be registered before parameterized route. r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers) r.Post("/containers/stale/{id}/cleanup", s.cleanupStaleContainer) // Settings endpoints. r.Put("/settings", s.updateSettings) r.Get("/settings/webhook-url", s.getWebhookURL) r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) // DNS management endpoints. r.Post("/settings/dns/test", s.testDNSConnection) r.Post("/settings/dns/zones", s.listDNSZones) r.Get("/dns/records", s.listDNSRecords) r.Post("/dns/sync", s.syncDNSRecords) r.Delete("/dns/records/{fqdn}", s.deleteDNSRecord) // Backup endpoints. r.Get("/backups", s.listBackups) r.Post("/backups", s.triggerBackup) r.Get("/backups/{id}/download", s.downloadBackup) r.Delete("/backups/{id}", s.deleteBackup) r.Post("/backups/{id}/restore", s.restoreBackup) }) }) }) return r }