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:
@@ -0,0 +1,365 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// dnsRecordView is the response format for DNS records with consumer context.
|
||||
type dnsRecordView struct {
|
||||
FQDN string `json:"fqdn"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
ConsumerType string `json:"consumer_type"`
|
||||
ConsumerName string `json:"consumer_name"`
|
||||
ConsumerID string `json:"consumer_id"`
|
||||
Status string `json:"status"` // "synced", "orphaned", "missing"
|
||||
}
|
||||
|
||||
// listDNSRecords handles GET /api/dns/records.
|
||||
// In managed DNS mode: merges local dns_records with actual Cloudflare records to compute sync status.
|
||||
// In wildcard mode: shows all expected FQDNs from active consumers (informational, no sync status).
|
||||
func (s *Server) listDNSRecords(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
consumerNames := s.buildConsumerNameMap()
|
||||
|
||||
// In wildcard mode, show expected records from consumers without sync status.
|
||||
if settings.WildcardDNS {
|
||||
expectedFQDNs := s.computeExpectedFQDNs(settings)
|
||||
var views []dnsRecordView
|
||||
for fqdn, consumer := range expectedFQDNs {
|
||||
parts := strings.SplitN(consumer, ":", 2)
|
||||
consumerType, consumerID := parts[0], ""
|
||||
if len(parts) > 1 {
|
||||
consumerID = parts[1]
|
||||
}
|
||||
name := consumerNames[consumer]
|
||||
if name == "" {
|
||||
name = consumerID
|
||||
}
|
||||
views = append(views, dnsRecordView{
|
||||
FQDN: fqdn,
|
||||
Type: "A",
|
||||
Content: settings.ServerIP,
|
||||
ConsumerType: consumerType,
|
||||
ConsumerName: name,
|
||||
ConsumerID: consumerID,
|
||||
Status: "wildcard",
|
||||
})
|
||||
}
|
||||
if views == nil {
|
||||
views = []dnsRecordView{}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, views)
|
||||
return
|
||||
}
|
||||
|
||||
// Managed DNS mode: full sync status computation.
|
||||
|
||||
// Get local tracked records.
|
||||
localRecords, err := s.store.ListDNSRecords()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list local records: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Try to get actual records from the DNS provider.
|
||||
var providerRecords []dns.Record
|
||||
provider := s.getOrCreateDNSProvider(settings)
|
||||
if provider != nil {
|
||||
providerRecords, err = provider.ListRecords(r.Context())
|
||||
if err != nil {
|
||||
slog.Warn("dns records: failed to list provider records", "error", err)
|
||||
// Continue with local-only view.
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of provider records by FQDN.
|
||||
providerByFQDN := make(map[string]dns.Record, len(providerRecords))
|
||||
for _, rec := range providerRecords {
|
||||
providerByFQDN[rec.FQDN] = rec
|
||||
}
|
||||
|
||||
// Build a set of local FQDNs.
|
||||
localFQDNs := make(map[string]bool, len(localRecords))
|
||||
for _, rec := range localRecords {
|
||||
localFQDNs[rec.FQDN] = true
|
||||
}
|
||||
|
||||
var views []dnsRecordView
|
||||
|
||||
// Process local records: check if they exist in provider.
|
||||
for _, local := range localRecords {
|
||||
status := "missing"
|
||||
content := settings.ServerIP
|
||||
if pRec, ok := providerByFQDN[local.FQDN]; ok {
|
||||
status = "synced"
|
||||
content = pRec.Content
|
||||
}
|
||||
|
||||
name := consumerNames[local.ConsumerType+":"+local.ConsumerID]
|
||||
if name == "" {
|
||||
name = local.ConsumerID
|
||||
}
|
||||
|
||||
views = append(views, dnsRecordView{
|
||||
FQDN: local.FQDN,
|
||||
Type: "A",
|
||||
Content: content,
|
||||
ConsumerType: local.ConsumerType,
|
||||
ConsumerName: name,
|
||||
ConsumerID: local.ConsumerID,
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
// Find orphaned records: in provider but not in local tracking.
|
||||
for _, pRec := range providerRecords {
|
||||
if !localFQDNs[pRec.FQDN] {
|
||||
views = append(views, dnsRecordView{
|
||||
FQDN: pRec.FQDN,
|
||||
Type: pRec.Type,
|
||||
Content: pRec.Content,
|
||||
ConsumerType: "",
|
||||
ConsumerName: "",
|
||||
ConsumerID: "",
|
||||
Status: "orphaned",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if views == nil {
|
||||
views = []dnsRecordView{}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, views)
|
||||
}
|
||||
|
||||
// deleteDNSRecord handles DELETE /api/dns/records/{fqdn}.
|
||||
func (s *Server) deleteDNSRecord(w http.ResponseWriter, r *http.Request) {
|
||||
fqdn := chi.URLParam(r, "fqdn")
|
||||
if fqdn == "" {
|
||||
respondError(w, http.StatusBadRequest, "fqdn is required")
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
provider := s.getOrCreateDNSProvider(settings)
|
||||
if provider != nil {
|
||||
if err := provider.DeleteRecord(r.Context(), fqdn); err != nil {
|
||||
respondError(w, http.StatusBadGateway, "failed to delete DNS record: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Remove local tracking.
|
||||
if err := s.store.DeleteDNSRecord(fqdn); err != nil {
|
||||
slog.Warn("delete dns tracking record", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS consumers.
|
||||
func (s *Server) buildConsumerNameMap() map[string]string {
|
||||
names := make(map[string]string)
|
||||
|
||||
// Instance consumers: "instance:id" -> "project/stage:tag"
|
||||
projects, _ := s.store.GetAllProjects()
|
||||
projectNames := make(map[string]string, len(projects))
|
||||
for _, p := range projects {
|
||||
projectNames[p.ID] = p.Name
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
stages, _ := s.store.GetStagesByProjectID(p.ID)
|
||||
for _, st := range stages {
|
||||
instances, _ := s.store.GetInstancesByStageID(st.ID)
|
||||
for _, inst := range instances {
|
||||
names["instance:"+inst.ID] = p.Name + "/" + st.Name + ":" + inst.ImageTag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone proxy consumers: "standalone:id" -> domain
|
||||
proxies, _ := s.store.ListStandaloneProxies()
|
||||
for _, p := range proxies {
|
||||
names["standalone:"+p.ID] = p.Domain
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
// 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 settings.WildcardDNS || settings.DNSProvider == "" || settings.CloudflareAPIToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
token, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
|
||||
if err != nil {
|
||||
slog.Warn("dns: failed to decrypt token for provider creation", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, err := dns.NewProvider(settings.DNSProvider, dns.Config{
|
||||
Token: token,
|
||||
ZoneID: settings.CloudflareZoneID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("dns: failed to create provider", "error", err)
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// syncDNSRecords handles POST /api/dns/sync.
|
||||
func (s *Server) syncDNSRecords(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if settings.WildcardDNS {
|
||||
respondError(w, http.StatusBadRequest, "DNS sync is disabled in wildcard mode")
|
||||
return
|
||||
}
|
||||
|
||||
provider := s.getOrCreateDNSProvider(settings)
|
||||
if provider == nil {
|
||||
respondError(w, http.StatusBadRequest, "DNS provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Compute expected FQDNs from active consumers.
|
||||
expectedFQDNs := s.computeExpectedFQDNs(settings)
|
||||
|
||||
// Get actual provider records.
|
||||
providerRecords, err := provider.ListRecords(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, "failed to list DNS records: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
providerByFQDN := make(map[string]dns.Record, len(providerRecords))
|
||||
for _, rec := range providerRecords {
|
||||
providerByFQDN[rec.FQDN] = rec
|
||||
}
|
||||
|
||||
// Get local tracking records.
|
||||
localRecords, err := s.store.ListDNSRecords()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list local records: "+err.Error())
|
||||
return
|
||||
}
|
||||
localByFQDN := make(map[string]bool, len(localRecords))
|
||||
for _, rec := range localRecords {
|
||||
localByFQDN[rec.FQDN] = true
|
||||
}
|
||||
|
||||
created := 0
|
||||
deleted := 0
|
||||
alreadySynced := 0
|
||||
|
||||
// Create missing records.
|
||||
for fqdn, consumer := range expectedFQDNs {
|
||||
if _, exists := providerByFQDN[fqdn]; exists {
|
||||
alreadySynced++
|
||||
continue
|
||||
}
|
||||
|
||||
recordID, err := provider.EnsureRecord(r.Context(), fqdn, settings.ServerIP)
|
||||
if err != nil {
|
||||
slog.Warn("dns sync: failed to create record", "fqdn", fqdn, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Track locally.
|
||||
parts := strings.SplitN(consumer, ":", 2)
|
||||
consumerType, consumerID := parts[0], ""
|
||||
if len(parts) > 1 {
|
||||
consumerID = parts[1]
|
||||
}
|
||||
if _, err := s.store.CreateDNSRecord(store.DNSRecord{
|
||||
FQDN: fqdn,
|
||||
ProviderRecordID: recordID,
|
||||
ConsumerType: consumerType,
|
||||
ConsumerID: consumerID,
|
||||
}); err != nil {
|
||||
s.store.UpdateDNSRecordProviderID(fqdn, recordID)
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
// Delete orphaned records (in provider + tracked locally, but no active consumer).
|
||||
for _, local := range localRecords {
|
||||
if _, expected := expectedFQDNs[local.FQDN]; !expected {
|
||||
if err := provider.DeleteRecord(r.Context(), local.FQDN); err != nil {
|
||||
slog.Warn("dns sync: failed to delete orphaned record", "fqdn", local.FQDN, "error", err)
|
||||
continue
|
||||
}
|
||||
s.store.DeleteDNSRecord(local.FQDN)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]int{
|
||||
"created": created,
|
||||
"deleted": deleted,
|
||||
"already_synced": alreadySynced,
|
||||
})
|
||||
}
|
||||
|
||||
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" for all active DNS consumers.
|
||||
func (s *Server) computeExpectedFQDNs(settings store.Settings) map[string]string {
|
||||
expected := make(map[string]string)
|
||||
|
||||
// Instances with proxy enabled.
|
||||
projects, _ := s.store.GetAllProjects()
|
||||
for _, p := range projects {
|
||||
stages, _ := s.store.GetStagesByProjectID(p.ID)
|
||||
for _, st := range stages {
|
||||
if !st.EnableProxy {
|
||||
continue
|
||||
}
|
||||
instances, _ := s.store.GetInstancesByStageID(st.ID)
|
||||
for _, inst := range instances {
|
||||
if inst.NpmProxyID > 0 && inst.Subdomain != "" && inst.Status == "running" {
|
||||
fqdn := inst.Subdomain + "." + settings.Domain
|
||||
expected[fqdn] = "instance:" + inst.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone proxies.
|
||||
proxies, _ := s.store.ListStandaloneProxies()
|
||||
for _, p := range proxies {
|
||||
if p.Domain != "" {
|
||||
expected[p.Domain] = "standalone:" + p.ID
|
||||
}
|
||||
}
|
||||
|
||||
return expected
|
||||
}
|
||||
Reference in New Issue
Block a user