Files
tiny-forge/internal/api/router.go
T
alexei.dolgolyov 670948f113 fix: address code review findings for DNS management
- CRITICAL: Change DNS zones endpoint from GET to POST to avoid
  leaking API token in URL query parameters
- HIGH: Add sync.RWMutex to protect dnsProvider field in Server,
  Deployer, and proxy Manager against concurrent read/write races
- HIGH: Capture old DNS provider reference synchronously before
  launching background cleanup goroutine
- HIGH: Use getDNS()/getDNSProviderLocked() accessors instead of
  direct field reads in all DNS operations
2026-04-02 14:54:15 +03:00

296 lines
9.1 KiB
Go

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/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
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
}
// NewServer creates a new API Server with all required dependencies.
func NewServer(
st *store.Store,
dockerClient *docker.Client,
npmClient *npm.Client,
deployer DeployTriggerer,
webhookHandler *webhook.Handler,
eventBus *events.Bus,
encKey [32]byte,
) *Server {
localAuth := auth.NewLocalAuth(encKey)
s := &Server{
store: st,
docker: dockerClient,
npm: npmClient,
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
}
// 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)
// 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.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.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)
})
})
})
return r
}