diff --git a/cmd/server/main.go b/cmd/server/main.go index 15524ea..b1066ca 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -290,6 +290,9 @@ func main() { apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) { dep.SetDNSProvider(provider) }) + apiServer.SetProxyProviderChangedCallback(func(provider proxy.Provider) { + dep.SetProxyProvider(provider) + }) router := apiServer.Router() // Serve embedded static files for the SPA frontend. diff --git a/internal/api/router.go b/internal/api/router.go index 710fa7e..7a12c07 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -46,6 +46,7 @@ type Server struct { 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. @@ -108,6 +109,16 @@ func (s *Server) SetBackupSettingsChangedCallback(fn func(enabled bool, interval 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() diff --git a/internal/api/settings.go b/internal/api/settings.go index 344089d..a2fdc1a 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -10,7 +10,9 @@ import ( "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/npm" + "github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/docker-watcher/internal/webhook" @@ -224,9 +226,12 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { return } - // If SSL cert changed, update all existing NPM proxy hosts in the background. - if sslChanged { - go s.reapplySSLToAllProxies(updated) + // If proxy-affecting settings changed, re-sync all proxy routes in the background. + proxyChanged := existing.Domain != updated.Domain || + existing.ProxyProvider != updated.ProxyProvider || + sslChanged + if proxyChanged { + go s.resyncAllProxies(existing, updated) } // Handle DNS provider changes. @@ -359,93 +364,128 @@ func isWildcardCert(cert npm.Certificate) bool { return false } -// reapplySSLToAllProxies updates all existing NPM proxy hosts managed by Docker Watcher -// to use the new SSL certificate. Runs in the background after settings change. -func (s *Server) reapplySSLToAllProxies(settings store.Settings) { +// createProxyProvider builds a proxy.Provider from the given settings. +func (s *Server) createProxyProvider(settings store.Settings) proxy.Provider { + switch settings.ProxyProvider { + case "npm": + if settings.NpmURL == "" || settings.NpmEmail == "" || settings.NpmPassword == "" { + slog.Warn("proxy resync: NPM credentials incomplete, falling back to none") + return proxy.NewNoneProvider() + } + npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) + if err != nil { + slog.Error("proxy resync: decrypt npm password", "error", err) + return proxy.NewNoneProvider() + } + return proxy.NewNpmProvider(npm.New(settings.NpmURL), settings.NpmEmail, npmPassword) + case "traefik": + return proxy.NewTraefikProvider( + settings.TraefikEntrypoint, + settings.TraefikCertResolver, + settings.TraefikNetwork, + settings.TraefikAPIURL, + ) + default: + return proxy.NewNoneProvider() + } +} + +// resyncAllProxies re-configures or removes proxy routes for all running instances +// when proxy-affecting settings change (domain, SSL cert, proxy provider). +// Runs in the background after settings save. +func (s *Server) resyncAllProxies(oldSettings, newSettings store.Settings) { ctx := context.Background() - npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) + // Collect all proxy-enabled instances. + routes, err := s.store.ListProxyRoutes(oldSettings.Domain) if err != nil { - slog.Error("reapply SSL: decrypt npm password", "error", err) + slog.Error("proxy resync: list routes", "error", err) return } - npmClient := npm.New(settings.NpmURL) - if err := npmClient.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { - slog.Error("reapply SSL: authenticate to NPM", "error", err) + if len(routes) == 0 { + slog.Info("proxy resync: no proxy routes to update") return } - // Get all proxy hosts from NPM. - hosts, err := npmClient.ListProxyHosts(ctx) - if err != nil { - slog.Error("reapply SSL: list proxy hosts", "error", err) - return - } + providerChanged := oldSettings.ProxyProvider != newSettings.ProxyProvider + domainChanged := oldSettings.Domain != newSettings.Domain - // Get all our managed instances to identify which proxy hosts are ours. - projects, err := s.store.GetAllProjects() - if err != nil { - slog.Error("reapply SSL: get projects", "error", err) - return - } - - // Build a set of NPM proxy IDs that belong to our instances. - managedProxyIDs := make(map[int]bool) - for _, p := range projects { - stages, err := s.store.GetStagesByProjectID(p.ID) - if err != nil { - continue - } - for _, st := range stages { - instances, err := s.store.GetInstancesByStageID(st.ID) - if err != nil { - continue - } - for _, inst := range instances { - if inst.NpmProxyID > 0 { - managedProxyIDs[inst.NpmProxyID] = true + // Step 1: If provider changed, delete old routes from the OLD provider, then switch. + if providerChanged { + slog.Info("proxy resync: provider changed", "old", oldSettings.ProxyProvider, "new", newSettings.ProxyProvider) + oldProvider := s.proxyProvider + for _, route := range routes { + if route.ProxyRouteID != "" { + if err := oldProvider.DeleteRoute(ctx, route.ProxyRouteID); err != nil { + slog.Warn("proxy resync: delete old route", "route_id", route.ProxyRouteID, "error", err) } } } + + // Create and install the new provider. + newProvider := s.createProxyProvider(newSettings) + s.SetProxyProvider(newProvider) + if s.onProxyProviderChanged != nil { + s.onProxyProviderChanged(newProvider) + } } + // Step 2: If new provider is "none", clear all proxy route IDs and we're done. + if newSettings.ProxyProvider == "none" { + for _, route := range routes { + inst, err := s.store.GetInstanceByID(route.InstanceID) + if err != nil { + continue + } + inst.ProxyRouteID = "" + inst.NpmProxyID = 0 + if err := s.store.UpdateInstance(inst); err != nil { + slog.Warn("proxy resync: clear route ID", "instance", route.InstanceID, "error", err) + } + } + slog.Info("proxy resync: cleared all proxy routes (provider set to none)", "count", len(routes)) + return + } + + // Step 3: Re-create/update routes with the current provider and new settings. updated := 0 - for _, host := range hosts { - if !managedProxyIDs[host.ID] { + for _, route := range routes { + if route.Subdomain == "" { continue } - config := npm.ProxyHostConfig{ - DomainNames: host.DomainNames, - ForwardScheme: host.ForwardScheme, - ForwardHost: host.ForwardHost, - ForwardPort: host.ForwardPort, - BlockExploits: true, - AllowWebsocket: true, - HTTP2Support: true, - Meta: npm.Meta{}, - Locations: []any{}, - } + fqdn := route.Subdomain + "." + newSettings.Domain - if settings.SSLCertificateID > 0 { - config.CertificateID = settings.SSLCertificateID - config.SSLForced = true - config.HSTSEnabled = true - } else { - config.CertificateID = 0 - config.SSLForced = false - config.HSTSEnabled = false - } + // Reconstruct the container name (Docker DNS name) from project/stage/tag. + containerName := docker.ContainerName(route.ProjectName, route.StageName, route.ImageTag) - if _, err := npmClient.UpdateProxyHost(ctx, host.ID, config); err != nil { - slog.Warn("reapply SSL: update proxy host failed", "host_id", host.ID, "error", err) + routeID, err := s.proxyProvider.ConfigureRoute(ctx, fqdn, containerName, route.Port, proxy.RouteOptions{ + SSLCertificateID: newSettings.SSLCertificateID, + }) + if err != nil { + slog.Warn("proxy resync: configure route failed", + "domain", fqdn, "instance", route.InstanceID, "error", err) continue } + + // Update instance with new route ID. + inst, err := s.store.GetInstanceByID(route.InstanceID) + if err != nil { + continue + } + inst.ProxyRouteID = routeID + if domainChanged { + // Subdomain stays the same, but the FQDN in external systems changed. + slog.Info("proxy resync: domain updated", "instance", route.InstanceID, "domain", fqdn) + } + if err := s.store.UpdateInstance(inst); err != nil { + slog.Warn("proxy resync: update instance", "instance", route.InstanceID, "error", err) + } updated++ } - slog.Info("reapply SSL: completed", "updated", updated, "total_managed", len(managedProxyIDs)) + slog.Info("proxy resync: completed", "updated", updated, "total", len(routes)) } // handleDNSSettingsChange reacts to DNS configuration changes: diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index bc4952c..6dba55a 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -67,6 +67,11 @@ func New( } } +// SetProxyProvider updates the proxy provider at runtime (e.g., when settings change). +func (d *Deployer) SetProxyProvider(provider proxy.Provider) { + d.proxy = provider +} + // SetDNSProvider sets the DNS provider for managing DNS records during deployments. // Pass nil to disable DNS management (wildcard DNS mode). func (d *Deployer) SetDNSProvider(provider dns.Provider) { diff --git a/server.exe~ b/server.exe~ deleted file mode 100644 index 0e47f96..0000000 Binary files a/server.exe~ and /dev/null differ