feat: Cloudflare DNS management with automatic record sync

Add flexible DNS management to Docker Watcher. By default, wildcard DNS
is assumed (current behavior). When disabled, users can configure a
Cloudflare DNS provider with API token and zone selection. DNS A records
are automatically created/updated/deleted in sync with proxy consumers
(deployed instances and standalone proxies).

- Settings: wildcard_dns toggle, dns_provider, cloudflare credentials
- Cloudflare client: Provider interface with EnsureRecord/DeleteRecord/ListRecords
- DNS lifecycle hooks in deployer and proxy manager (best-effort)
- Settings UI: DNS config section with provider picker, zone selector, test button
- DNS Records page at /dns with filtering, sync status, reconciliation
- Records visible in both wildcard and managed modes
- Cleanup on provider change: removes old records when switching modes
This commit is contained in:
2026-04-02 14:49:21 +03:00
parent c9d4895ee3
commit c730cfaa45
46 changed files with 2429 additions and 1260 deletions
+69
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -14,6 +15,7 @@ import (
type Manager struct {
store *store.Store
npm *npm.Client
dns dns.Provider // nil when wildcard DNS is active
}
// NewManager creates a new proxy manager.
@@ -24,6 +26,11 @@ 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.dns = provider
}
// CreateProxyRequest is the input for creating a standalone proxy.
type CreateProxyRequest struct {
Domain string `json:"domain"`
@@ -108,6 +115,9 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor
return store.StandaloneProxy{}, fmt.Errorf("save standalone proxy: %w", err)
}
// Create DNS record after successful store save.
m.ensureDNS(ctx, req.Domain, proxy.ID)
return proxy, nil
}
@@ -160,6 +170,12 @@ func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyReq
return store.StandaloneProxy{}, fmt.Errorf("update standalone proxy: %w", err)
}
// Update DNS records if domain changed.
if existing.Domain != req.Domain {
m.removeDNS(ctx, existing.Domain)
m.ensureDNS(ctx, req.Domain, id)
}
// Re-read from store to get updated timestamps.
return m.store.GetStandaloneProxy(id)
}
@@ -179,6 +195,9 @@ func (m *Manager) DeleteProxy(ctx context.Context, id string) error {
}
}
// Remove DNS record.
m.removeDNS(ctx, proxy.Domain)
if err := m.store.DeleteStandaloneProxy(id); err != nil {
return fmt.Errorf("delete standalone proxy: %w", err)
}
@@ -294,6 +313,56 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) {
return views, nil
}
// 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 {
return
}
settings, err := m.store.GetSettings()
if err != nil {
slog.Warn("dns: get settings for server IP", "error", err)
return
}
if settings.ServerIP == "" {
slog.Warn("dns: server IP not configured, skipping DNS record creation", "domain", domain)
return
}
recordID, err := m.dns.EnsureRecord(ctx, domain, settings.ServerIP)
if err != nil {
slog.Warn("dns: failed to create/update record for standalone proxy", "domain", domain, "error", err)
return
}
if _, err := m.store.CreateDNSRecord(store.DNSRecord{
FQDN: domain,
ProviderRecordID: recordID,
ConsumerType: "standalone",
ConsumerID: proxyID,
}); err != nil {
// May already exist — try updating.
if updateErr := m.store.UpdateDNSRecordProviderID(domain, recordID); updateErr != nil {
slog.Warn("dns: failed to track record", "domain", domain, "error", updateErr)
}
}
slog.Info("dns: record ensured for standalone proxy", "domain", domain)
}
// 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 {
return
}
if err := m.dns.DeleteRecord(ctx, domain); err != nil {
slog.Warn("dns: failed to delete record for standalone proxy", "domain", domain, "error", err)
return
}
if err := m.store.DeleteDNSRecord(domain); err != nil {
slog.Warn("dns: failed to remove tracking record", "domain", domain, "error", err)
}
slog.Info("dns: record deleted for standalone proxy", "domain", domain)
}
// lastFailedStep returns the message of the last failed validation step.
func lastFailedStep(result ValidationResult) string {
for _, step := range result.Steps {