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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user