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
This commit is contained in:
2026-04-02 14:54:15 +03:00
parent c730cfaa45
commit 670948f113
243 changed files with 15971 additions and 535 deletions
+2 -2
View File
@@ -208,8 +208,8 @@ func (s *Server) buildConsumerNameMap() map[string]string {
// getOrCreateDNSProvider returns the server's DNS provider, or creates a temporary one from settings.
func (s *Server) getOrCreateDNSProvider(settings store.Settings) dns.Provider {
if s.dnsProvider != nil {
return s.dnsProvider
if p := s.getDNSProviderLocked(); p != nil {
return p
}
if settings.WildcardDNS || settings.DNSProvider == "" || settings.CloudflareAPIToken == "" {
+13 -2
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"log/slog"
"sync"
"github.com/go-chi/chi/v5"
@@ -36,7 +37,8 @@ type Server struct {
staleScanner *stale.Scanner
proxyManager *proxy.Manager
dnsProvider dns.Provider
dnsProviderMu sync.RWMutex
dnsProvider dns.Provider
onDNSProviderChanged DNSProviderChangedFunc
}
@@ -86,9 +88,18 @@ func (s *Server) SetProxyManager(pm *proxy.Manager) {
// 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
@@ -272,7 +283,7 @@ func (s *Server) Router() chi.Router {
// DNS management endpoints.
r.Post("/settings/dns/test", s.testDNSConnection)
r.Get("/settings/dns/zones", s.listDNSZones)
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)
+18 -8
View File
@@ -176,7 +176,8 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
existing.CloudflareZoneID != updated.CloudflareZoneID ||
(req.CloudflareAPIToken != "" && req.CloudflareAPIToken != "unchanged")
if dnsChanged {
go s.handleDNSSettingsChange(existing, updated)
oldProvider := s.getDNSProviderLocked()
go s.handleDNSSettingsChange(oldProvider, existing, updated)
}
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
@@ -374,17 +375,17 @@ func (s *Server) reapplySSLToAllProxies(settings store.Settings) {
// handleDNSSettingsChange reacts to DNS configuration changes:
// - If switching to wildcard mode: remove all managed DNS records from the provider.
// - If switching provider or credentials: remove old records, create new provider, re-sync.
func (s *Server) handleDNSSettingsChange(oldSettings, newSettings store.Settings) {
func (s *Server) handleDNSSettingsChange(oldProvider dns.Provider, oldSettings, newSettings store.Settings) {
ctx := context.Background()
// Step 1: If there was an old provider, remove all managed DNS records from it.
if !oldSettings.WildcardDNS && oldSettings.DNSProvider != "" && s.dnsProvider != nil {
if !oldSettings.WildcardDNS && oldSettings.DNSProvider != "" && oldProvider != nil {
records, err := s.store.ListDNSRecords()
if err != nil {
slog.Error("dns settings change: list records for cleanup", "error", err)
} else {
for _, rec := range records {
if err := s.dnsProvider.DeleteRecord(ctx, rec.FQDN); err != nil {
if err := oldProvider.DeleteRecord(ctx, rec.FQDN); err != nil {
slog.Warn("dns settings change: delete old record", "fqdn", rec.FQDN, "error", err)
}
if err := s.store.DeleteDNSRecord(rec.FQDN); err != nil {
@@ -420,7 +421,7 @@ func (s *Server) handleDNSSettingsChange(oldSettings, newSettings store.Settings
}
// Step 3: Update the server's DNS provider and notify dependents.
s.dnsProvider = newProvider
s.SetDNSProvider(newProvider)
if s.onDNSProviderChanged != nil {
s.onDNSProviderChanged(newProvider)
}
@@ -488,10 +489,19 @@ func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) {
})
}
// listDNSZones handles GET /api/settings/dns/zones.
// dnsZonesRequest is the expected JSON body for listing DNS zones.
type dnsZonesRequest struct {
Token string `json:"token"`
}
// listDNSZones handles POST /api/settings/dns/zones.
func (s *Server) listDNSZones(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
// If no token in query, use stored one.
var req dnsZonesRequest
if !decodeJSON(w, r, &req) {
return
}
token := req.Token
// If no token in body, use stored one.
if token == "" {
settings, err := s.store.GetSettings()
if err != nil {
+16 -4
View File
@@ -33,6 +33,7 @@ type Deployer struct {
notifier *notify.Notifier
eventBus EventPublisher
encKey [32]byte
dnsMu sync.RWMutex
dns dns.Provider // nil when wildcard DNS is active
// Graceful shutdown: tracks in-progress deploys.
@@ -69,9 +70,18 @@ func New(
// 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) {
d.dnsMu.Lock()
defer d.dnsMu.Unlock()
d.dns = provider
}
// getDNS returns the current DNS provider under read lock.
func (d *Deployer) getDNS() dns.Provider {
d.dnsMu.RLock()
defer d.dnsMu.RUnlock()
return d.dns
}
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
func (d *Deployer) Drain() {
d.shuttingDown.Store(true)
@@ -744,7 +754,8 @@ func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status
// ensureDNS creates or updates a DNS record for the given FQDN. Best-effort: logs warnings on failure.
func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID, deployID string) {
if d.dns == nil {
dnsProvider := d.getDNS()
if dnsProvider == nil {
return
}
settings, err := d.store.GetSettings()
@@ -757,7 +768,7 @@ func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID
return
}
recordID, err := d.dns.EnsureRecord(ctx, fqdn, settings.ServerIP)
recordID, err := dnsProvider.EnsureRecord(ctx, fqdn, settings.ServerIP)
if err != nil {
msg := fmt.Sprintf("DNS: failed to create/update record for %s: %v", fqdn, err)
slog.Warn(msg)
@@ -789,11 +800,12 @@ func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID
// removeDNS deletes a DNS record for the given FQDN. Best-effort: logs warnings on failure.
func (d *Deployer) removeDNS(ctx context.Context, fqdn, deployID string) {
if d.dns == nil {
dnsProvider := d.getDNS()
if dnsProvider == nil {
return
}
if err := d.dns.DeleteRecord(ctx, fqdn); err != nil {
if err := dnsProvider.DeleteRecord(ctx, fqdn); err != nil {
msg := fmt.Sprintf("DNS: failed to delete record for %s: %v", fqdn, err)
slog.Warn(msg)
if deployID != "" {
+17 -4
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"sync"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/npm"
@@ -15,6 +16,7 @@ import (
type Manager struct {
store *store.Store
npm *npm.Client
dnsMu sync.RWMutex
dns dns.Provider // nil when wildcard DNS is active
}
@@ -28,9 +30,18 @@ func NewManager(st *store.Store, npmClient *npm.Client) *Manager {
// SetDNSProvider sets the DNS provider for managing DNS records.
func (m *Manager) SetDNSProvider(provider dns.Provider) {
m.dnsMu.Lock()
defer m.dnsMu.Unlock()
m.dns = provider
}
// getDNS returns the current DNS provider under read lock.
func (m *Manager) getDNS() dns.Provider {
m.dnsMu.RLock()
defer m.dnsMu.RUnlock()
return m.dns
}
// CreateProxyRequest is the input for creating a standalone proxy.
type CreateProxyRequest struct {
Domain string `json:"domain"`
@@ -315,7 +326,8 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) {
// ensureDNS creates or updates a DNS record for a standalone proxy domain. Best-effort.
func (m *Manager) ensureDNS(ctx context.Context, domain, proxyID string) {
if m.dns == nil {
dnsProvider := m.getDNS()
if dnsProvider == nil {
return
}
settings, err := m.store.GetSettings()
@@ -328,7 +340,7 @@ func (m *Manager) ensureDNS(ctx context.Context, domain, proxyID string) {
return
}
recordID, err := m.dns.EnsureRecord(ctx, domain, settings.ServerIP)
recordID, err := dnsProvider.EnsureRecord(ctx, domain, settings.ServerIP)
if err != nil {
slog.Warn("dns: failed to create/update record for standalone proxy", "domain", domain, "error", err)
return
@@ -350,10 +362,11 @@ func (m *Manager) ensureDNS(ctx context.Context, domain, proxyID string) {
// removeDNS deletes a DNS record for a standalone proxy domain. Best-effort.
func (m *Manager) removeDNS(ctx context.Context, domain string) {
if m.dns == nil {
dnsProvider := m.getDNS()
if dnsProvider == nil {
return
}
if err := m.dns.DeleteRecord(ctx, domain); err != nil {
if err := dnsProvider.DeleteRecord(ctx, domain); err != nil {
slog.Warn("dns: failed to delete record for standalone proxy", "domain", domain, "error", err)
return
}