package api import ( "context" "log/slog" "sync" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/backup" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/dns" "github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/events" "github.com/alexei/tinyforge/internal/notify" "github.com/alexei/tinyforge/internal/npm" "github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/stack" "github.com/alexei/tinyforge/internal/stale" "github.com/alexei/tinyforge/internal/staticsite" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/webhook" ) // DNSProviderChangedFunc is called when DNS settings change so the caller can // update the provider on the deployer. 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 notifier *notify.Notifier webhook *webhook.Handler eventBus *events.Bus encKey [32]byte localAuth *auth.LocalAuth oidcProvider *auth.OIDCProvider staleScanner *stale.Scanner dnsProviderMu sync.RWMutex dnsProvider dns.Provider onDNSProviderChanged DNSProviderChangedFunc staticSiteManager *staticsite.Manager stackManager *stack.Manager backupEngine *backup.Engine sseGate *sseGate dbPath string shutdownFunc func() // called after restore to trigger graceful shutdown onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes } // 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, notifier *notify.Notifier, 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, notifier: notifier, webhook: webhookHandler, eventBus: eventBus, encKey: encKey, localAuth: localAuth, sseGate: newSSEGate(maxConcurrentSSEStreams), } // 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 } // SetStaticSiteManager sets the static site manager on the server. func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) { s.staticSiteManager = mgr } // SetStackManager sets the docker-compose stack manager on the server. func (s *Server) SetStackManager(mgr *stack.Manager) { s.stackManager = mgr } // 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 } // 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 } // SetProxyProviderChangedCallback sets the callback for when the proxy provider changes. func (s *Server) SetProxyProviderChangedCallback(fn func(provider proxy.Provider)) { s.onProxyProviderChanged = fn } // SetProxyProvider updates the proxy provider at runtime. func (s *Server) SetProxyProvider(provider proxy.Provider) { s.proxyProvider = provider } // 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() webhookLimiter := 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.Get("/auth/mode", s.authMode) 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). // Per-IP rate limit prevents an attacker who has guessed (or leaked) // a secret from triggering a deploy storm, and rejects unauthenticated // brute-force probes over the secret URL space. r.With(rateLimitMiddleware(webhookLimiter)).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("/proxies", s.listProxyRoutes) r.Get("/docker/unused-images", s.unusedImageStats) 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("/stages/{stage}/instances/{iid}/stats/history", s.getInstanceStatsHistory) r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs) r.Get("/images", s.listProjectImages) 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) // Per-project webhook URL management. r.Get("/webhook", s.getProjectWebhook) r.Post("/webhook/regenerate", s.regenerateProjectWebhook) // Inbound HMAC signing — secret rotation + enforcement toggle. r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret) r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret) r.Put("/webhook/require-signature", s.updateProjectSigningRequirement) r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries) // Per-project outgoing-webhook signing & test. r.Get("/notification-secret", s.getProjectNotificationSecret) r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret) r.Post("/notification-secret/disable", s.disableProjectNotificationSigning) r.Post("/notification-test", s.projectNotificationTest) // Stage endpoints. r.Post("/stages", s.createStage) r.Put("/stages/{stage}", s.updateStage) r.Delete("/stages/{stage}", s.deleteStage) // Per-stage outgoing-webhook signing & test. r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret) r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret) r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning) r.Post("/stages/{stage}/notification-test", s.stageNotificationTest) // 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) }) }) // Stacks (docker-compose). r.Get("/stacks", s.listStacks) r.Route("/stacks/{id}", func(r chi.Router) { r.Get("/", s.getStack) r.Get("/revisions", s.listStackRevisions) r.Get("/revisions/{revId}", s.getStackRevision) r.Get("/services", s.getStackServices) r.Get("/logs", s.getStackLogs) r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateStack) r.Delete("/", s.deleteStack) r.Post("/revisions", s.createStackRevision) r.Post("/rollback/{revId}", s.rollbackStack) r.Post("/stop", s.stopStack) r.Post("/start", s.startStack) }) }) r.With(auth.AdminOnly).Post("/stacks", s.createStack) // Static sites. r.Get("/sites", s.listStaticSites) r.Route("/sites/{id}", func(r chi.Router) { r.Get("/", s.getStaticSite) r.Get("/secrets", s.listStaticSiteSecrets) r.Get("/storage", s.getStaticSiteStorage) r.Get("/logs", s.streamStaticSiteLogs) r.Get("/stats", s.getStaticSiteStats) r.Get("/stats/history", s.getStaticSiteStatsHistory) // Admin-only mutations. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateStaticSite) r.Delete("/", s.deleteStaticSite) r.Post("/deploy", s.deployStaticSite) r.Post("/stop", s.stopStaticSite) r.Post("/start", s.startStaticSite) r.Get("/webhook", s.getStaticSiteWebhook) r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook) r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret) r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret) r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement) r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries) // Per-site outgoing-webhook signing & test. r.Get("/notification-secret", s.getStaticSiteNotificationSecret) r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret) r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning) r.Post("/notification-test", s.staticSiteNotificationTest) r.Post("/secrets", s.createStaticSiteSecret) r.Put("/secrets/{sid}", s.updateStaticSiteSecret) r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret) }) }) 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) r.Get("/settings/npm-access-lists", s.listNpmAccessLists) // Volume scope metadata (read-only). r.Get("/volumes/scopes", s.listVolumeScopes) // Stale container endpoints (read). r.Get("/containers/stale", s.listStaleContainers) // Workload-shaped endpoints (the unifying layer over project / // stack / site). Read-only; mutations still go through the // kind-specific endpoints (POST /projects, PUT /stacks/{id}, …). r.Get("/workloads", s.listWorkloads) r.Route("/workloads/{id}", func(r chi.Router) { r.Get("/", s.getWorkload) r.Get("/containers", s.listWorkloadContainers) r.With(auth.AdminOnly).Patch("/app", s.updateWorkloadAppID) }) // Global container index, joined to workload + app names. r.Get("/containers", s.listAllContainers) r.Get("/containers/{id}", s.getContainer) // App grouping (optional UI; admin-gated mutations). r.Get("/apps", s.listApps) r.Get("/apps/{id}", s.getApp) r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Post("/apps", s.createApp) r.Put("/apps/{id}", s.updateApp) r.Delete("/apps/{id}", s.deleteApp) }) // System resources (read-only). r.Get("/system/stats", s.getSystemStats) r.Get("/system/stats/history", s.getSystemStatsHistory) r.Get("/system/stats/top", s.listTopContainers) // 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) // Event log management. r.Delete("/events/log/{id}", s.deleteEvent) r.Delete("/events/log", s.clearEvents) // 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) // Static site creation and tools. r.Post("/sites", s.createStaticSite) r.Post("/sites/test-connection", s.testStaticSiteConnection) r.Post("/sites/branches", s.listStaticSiteBranches) r.Post("/sites/tree", s.listStaticSiteTree) r.Post("/sites/detect-provider", s.detectStaticSiteProvider) r.Post("/sites/repos", s.listStaticSiteRepos) // Quick deploy endpoints. r.Post("/deploy/inspect", s.inspectImage) r.Post("/deploy/quick", s.quickDeploy) // Registry creation. r.Post("/registries", s.createRegistry) // 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) // Global outgoing-webhook signing & test. r.Get("/settings/notification-secret", s.getSettingsNotificationSecret) r.Post("/settings/notification-secret/regenerate", s.regenerateSettingsNotificationSecret) r.Post("/settings/notification-secret/disable", s.disableSettingsNotificationSigning) r.Post("/settings/notification-test", s.settingsNotificationTest) // Docker management. r.Post("/docker/prune-images", s.pruneImages) // NPM connection test. r.Post("/settings/npm/test", s.testNpmConnection) // 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 }