feat: automatic proxy re-sync on settings change

When domain, SSL certificate, or proxy provider changes in settings:
- Delete old proxy routes from the previous provider
- Switch to None: clear all route IDs on instances
- Switch to NPM/Traefik: re-create routes with new settings
- Domain change: re-configure all routes with new FQDN
- SSL cert change: re-apply to all existing routes
- Provider created dynamically at runtime via createProxyProvider()
- Deployer and API server updated via SetProxyProvider callback
This commit is contained in:
2026-04-05 01:39:01 +03:00
parent 187e302f4a
commit 61febefca9
5 changed files with 123 additions and 64 deletions
+3
View File
@@ -290,6 +290,9 @@ func main() {
apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) { apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) {
dep.SetDNSProvider(provider) dep.SetDNSProvider(provider)
}) })
apiServer.SetProxyProviderChangedCallback(func(provider proxy.Provider) {
dep.SetProxyProvider(provider)
})
router := apiServer.Router() router := apiServer.Router()
// Serve embedded static files for the SPA frontend. // Serve embedded static files for the SPA frontend.
+11
View File
@@ -46,6 +46,7 @@ type Server struct {
dbPath string dbPath string
shutdownFunc func() // called after restore to trigger graceful shutdown shutdownFunc func() // called after restore to trigger graceful shutdown
onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change 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. // 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 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. // SetDNSProvider sets the current DNS provider on the server.
func (s *Server) SetDNSProvider(provider dns.Provider) { func (s *Server) SetDNSProvider(provider dns.Provider) {
s.dnsProviderMu.Lock() s.dnsProviderMu.Lock()
+104 -64
View File
@@ -10,7 +10,9 @@ import (
"github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns" "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/npm"
"github.com/alexei/docker-watcher/internal/proxy"
"github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/docker-watcher/internal/volume"
"github.com/alexei/docker-watcher/internal/webhook" "github.com/alexei/docker-watcher/internal/webhook"
@@ -224,9 +226,12 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
// If SSL cert changed, update all existing NPM proxy hosts in the background. // If proxy-affecting settings changed, re-sync all proxy routes in the background.
if sslChanged { proxyChanged := existing.Domain != updated.Domain ||
go s.reapplySSLToAllProxies(updated) existing.ProxyProvider != updated.ProxyProvider ||
sslChanged
if proxyChanged {
go s.resyncAllProxies(existing, updated)
} }
// Handle DNS provider changes. // Handle DNS provider changes.
@@ -359,93 +364,128 @@ func isWildcardCert(cert npm.Certificate) bool {
return false return false
} }
// reapplySSLToAllProxies updates all existing NPM proxy hosts managed by Docker Watcher // createProxyProvider builds a proxy.Provider from the given settings.
// to use the new SSL certificate. Runs in the background after settings change. func (s *Server) createProxyProvider(settings store.Settings) proxy.Provider {
func (s *Server) reapplySSLToAllProxies(settings store.Settings) { 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() 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 { if err != nil {
slog.Error("reapply SSL: decrypt npm password", "error", err) slog.Error("proxy resync: list routes", "error", err)
return return
} }
npmClient := npm.New(settings.NpmURL) if len(routes) == 0 {
if err := npmClient.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { slog.Info("proxy resync: no proxy routes to update")
slog.Error("reapply SSL: authenticate to NPM", "error", err)
return return
} }
// Get all proxy hosts from NPM. providerChanged := oldSettings.ProxyProvider != newSettings.ProxyProvider
hosts, err := npmClient.ListProxyHosts(ctx) domainChanged := oldSettings.Domain != newSettings.Domain
if err != nil {
slog.Error("reapply SSL: list proxy hosts", "error", err)
return
}
// Get all our managed instances to identify which proxy hosts are ours. // Step 1: If provider changed, delete old routes from the OLD provider, then switch.
projects, err := s.store.GetAllProjects() if providerChanged {
if err != nil { slog.Info("proxy resync: provider changed", "old", oldSettings.ProxyProvider, "new", newSettings.ProxyProvider)
slog.Error("reapply SSL: get projects", "error", err) oldProvider := s.proxyProvider
return for _, route := range routes {
} if route.ProxyRouteID != "" {
if err := oldProvider.DeleteRoute(ctx, route.ProxyRouteID); err != nil {
// Build a set of NPM proxy IDs that belong to our instances. slog.Warn("proxy resync: delete old route", "route_id", route.ProxyRouteID, "error", err)
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
} }
} }
} }
// 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 updated := 0
for _, host := range hosts { for _, route := range routes {
if !managedProxyIDs[host.ID] { if route.Subdomain == "" {
continue continue
} }
config := npm.ProxyHostConfig{ fqdn := route.Subdomain + "." + newSettings.Domain
DomainNames: host.DomainNames,
ForwardScheme: host.ForwardScheme,
ForwardHost: host.ForwardHost,
ForwardPort: host.ForwardPort,
BlockExploits: true,
AllowWebsocket: true,
HTTP2Support: true,
Meta: npm.Meta{},
Locations: []any{},
}
if settings.SSLCertificateID > 0 { // Reconstruct the container name (Docker DNS name) from project/stage/tag.
config.CertificateID = settings.SSLCertificateID containerName := docker.ContainerName(route.ProjectName, route.StageName, route.ImageTag)
config.SSLForced = true
config.HSTSEnabled = true
} else {
config.CertificateID = 0
config.SSLForced = false
config.HSTSEnabled = false
}
if _, err := npmClient.UpdateProxyHost(ctx, host.ID, config); err != nil { routeID, err := s.proxyProvider.ConfigureRoute(ctx, fqdn, containerName, route.Port, proxy.RouteOptions{
slog.Warn("reapply SSL: update proxy host failed", "host_id", host.ID, "error", err) SSLCertificateID: newSettings.SSLCertificateID,
})
if err != nil {
slog.Warn("proxy resync: configure route failed",
"domain", fqdn, "instance", route.InstanceID, "error", err)
continue 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++ 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: // handleDNSSettingsChange reacts to DNS configuration changes:
+5
View File
@@ -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. // SetDNSProvider sets the DNS provider for managing DNS records during deployments.
// Pass nil to disable DNS management (wildcard DNS mode). // Pass nil to disable DNS management (wildcard DNS mode).
func (d *Deployer) SetDNSProvider(provider dns.Provider) { func (d *Deployer) SetDNSProvider(provider dns.Provider) {
BIN
View File
Binary file not shown.