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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
+104
-64
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
BIN
Binary file not shown.
Reference in New Issue
Block a user