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:
+204
-13
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/alexei/docker-watcher/internal/volume"
|
||||
@@ -29,6 +30,10 @@ type settingsRequest struct {
|
||||
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
|
||||
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
|
||||
AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"`
|
||||
WildcardDNS *bool `json:"wildcard_dns,omitempty"`
|
||||
DNSProvider *string `json:"dns_provider,omitempty"`
|
||||
CloudflareAPIToken string `json:"cloudflare_api_token"`
|
||||
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
|
||||
}
|
||||
|
||||
// getSettings handles GET /api/settings.
|
||||
@@ -41,19 +46,23 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Return settings without sensitive fields.
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"domain": settings.Domain,
|
||||
"server_ip": settings.ServerIP,
|
||||
"network": settings.Network,
|
||||
"subdomain_pattern": settings.SubdomainPattern,
|
||||
"notification_url": settings.NotificationURL,
|
||||
"npm_url": settings.NpmURL,
|
||||
"npm_email": settings.NpmEmail,
|
||||
"has_npm_password": settings.NpmPassword != "",
|
||||
"polling_interval": settings.PollingInterval,
|
||||
"ssl_certificate_id": settings.SSLCertificateID,
|
||||
"stale_threshold_days": settings.StaleThresholdDays,
|
||||
"allowed_volume_paths": settings.AllowedVolumePaths,
|
||||
"updated_at": settings.UpdatedAt,
|
||||
"domain": settings.Domain,
|
||||
"server_ip": settings.ServerIP,
|
||||
"network": settings.Network,
|
||||
"subdomain_pattern": settings.SubdomainPattern,
|
||||
"notification_url": settings.NotificationURL,
|
||||
"npm_url": settings.NpmURL,
|
||||
"npm_email": settings.NpmEmail,
|
||||
"has_npm_password": settings.NpmPassword != "",
|
||||
"polling_interval": settings.PollingInterval,
|
||||
"ssl_certificate_id": settings.SSLCertificateID,
|
||||
"stale_threshold_days": settings.StaleThresholdDays,
|
||||
"allowed_volume_paths": settings.AllowedVolumePaths,
|
||||
"wildcard_dns": settings.WildcardDNS,
|
||||
"dns_provider": settings.DNSProvider,
|
||||
"has_cloudflare_api_token": settings.CloudflareAPIToken != "",
|
||||
"cloudflare_zone_id": settings.CloudflareZoneID,
|
||||
"updated_at": settings.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,6 +141,25 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
_ = paths // validated
|
||||
}
|
||||
|
||||
// DNS settings.
|
||||
if req.WildcardDNS != nil {
|
||||
updated.WildcardDNS = *req.WildcardDNS
|
||||
}
|
||||
if req.DNSProvider != nil {
|
||||
updated.DNSProvider = *req.DNSProvider
|
||||
}
|
||||
if req.CloudflareAPIToken != "" {
|
||||
encToken, err := crypto.Encrypt(s.encKey, req.CloudflareAPIToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to encrypt cloudflare api token: "+err.Error())
|
||||
return
|
||||
}
|
||||
updated.CloudflareAPIToken = encToken
|
||||
}
|
||||
if req.CloudflareZoneID != nil {
|
||||
updated.CloudflareZoneID = *req.CloudflareZoneID
|
||||
}
|
||||
|
||||
if err := s.store.UpdateSettings(updated); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||
return
|
||||
@@ -142,6 +170,15 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
go s.reapplySSLToAllProxies(updated)
|
||||
}
|
||||
|
||||
// Handle DNS provider changes.
|
||||
dnsChanged := existing.WildcardDNS != updated.WildcardDNS ||
|
||||
existing.DNSProvider != updated.DNSProvider ||
|
||||
existing.CloudflareZoneID != updated.CloudflareZoneID ||
|
||||
(req.CloudflareAPIToken != "" && req.CloudflareAPIToken != "unchanged")
|
||||
if dnsChanged {
|
||||
go s.handleDNSSettingsChange(existing, updated)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
@@ -334,3 +371,157 @@ func (s *Server) reapplySSLToAllProxies(settings store.Settings) {
|
||||
slog.Info("reapply SSL: completed", "updated", updated, "total_managed", len(managedProxyIDs))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
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 {
|
||||
slog.Warn("dns settings change: delete old record", "fqdn", rec.FQDN, "error", err)
|
||||
}
|
||||
if err := s.store.DeleteDNSRecord(rec.FQDN); err != nil {
|
||||
slog.Warn("dns settings change: remove tracking record", "fqdn", rec.FQDN, "error", err)
|
||||
}
|
||||
}
|
||||
slog.Info("dns settings change: cleaned up old records", "count", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create new provider (or nil for wildcard mode).
|
||||
var newProvider dns.Provider
|
||||
if !newSettings.WildcardDNS && newSettings.DNSProvider != "" {
|
||||
token := newSettings.CloudflareAPIToken
|
||||
if token != "" {
|
||||
decrypted, err := crypto.Decrypt(s.encKey, token)
|
||||
if err != nil {
|
||||
slog.Error("dns settings change: decrypt token", "error", err)
|
||||
return
|
||||
}
|
||||
token = decrypted
|
||||
}
|
||||
|
||||
provider, err := dns.NewProvider(newSettings.DNSProvider, dns.Config{
|
||||
Token: token,
|
||||
ZoneID: newSettings.CloudflareZoneID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("dns settings change: create provider", "error", err)
|
||||
return
|
||||
}
|
||||
newProvider = provider
|
||||
}
|
||||
|
||||
// Step 3: Update the server's DNS provider and notify dependents.
|
||||
s.dnsProvider = newProvider
|
||||
if s.onDNSProviderChanged != nil {
|
||||
s.onDNSProviderChanged(newProvider)
|
||||
}
|
||||
|
||||
slog.Info("dns settings change: provider updated",
|
||||
"wildcard", newSettings.WildcardDNS,
|
||||
"provider", newSettings.DNSProvider)
|
||||
}
|
||||
|
||||
// dnsTestRequest is the expected JSON body for testing DNS provider credentials.
|
||||
type dnsTestRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Token string `json:"token"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
}
|
||||
|
||||
// testDNSConnection handles POST /api/settings/dns/test.
|
||||
func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) {
|
||||
var req dnsTestRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Provider != "cloudflare" {
|
||||
respondError(w, http.StatusBadRequest, "unsupported DNS provider: "+req.Provider)
|
||||
return
|
||||
}
|
||||
|
||||
token := req.Token
|
||||
// If no token provided, use the stored one.
|
||||
if token == "" {
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
if settings.CloudflareAPIToken == "" {
|
||||
respondError(w, http.StatusBadRequest, "no Cloudflare API token configured")
|
||||
return
|
||||
}
|
||||
decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error())
|
||||
return
|
||||
}
|
||||
token = decrypted
|
||||
}
|
||||
|
||||
provider, err := dns.NewCloudflare(token, req.ZoneID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := provider.TestConnection(r.Context()); err != nil {
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
// listDNSZones handles GET /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.
|
||||
if token == "" {
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
if settings.CloudflareAPIToken == "" {
|
||||
respondError(w, http.StatusBadRequest, "no Cloudflare API token configured")
|
||||
return
|
||||
}
|
||||
decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error())
|
||||
return
|
||||
}
|
||||
token = decrypted
|
||||
}
|
||||
|
||||
provider, err := dns.NewCloudflare(token, "")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
zones, err := provider.ListZones(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, "failed to list zones: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, zones)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user