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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/crypto"
|
||||
"github.com/alexei/docker-watcher/internal/dns"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/npm"
|
||||
@@ -17,6 +18,10 @@ import (
|
||||
"github.com/alexei/docker-watcher/internal/webhook"
|
||||
)
|
||||
|
||||
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
||||
// update the provider on the deployer and proxy manager.
|
||||
type DNSProviderChangedFunc func(provider dns.Provider)
|
||||
|
||||
// Server holds all dependencies for the API layer.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
@@ -30,6 +35,9 @@ type Server struct {
|
||||
oidcProvider *auth.OIDCProvider
|
||||
staleScanner *stale.Scanner
|
||||
proxyManager *proxy.Manager
|
||||
|
||||
dnsProvider dns.Provider
|
||||
onDNSProviderChanged DNSProviderChangedFunc
|
||||
}
|
||||
|
||||
// NewServer creates a new API Server with all required dependencies.
|
||||
@@ -76,6 +84,16 @@ func (s *Server) SetProxyManager(pm *proxy.Manager) {
|
||||
s.proxyManager = pm
|
||||
}
|
||||
|
||||
// SetDNSProvider sets the current DNS provider on the server.
|
||||
func (s *Server) SetDNSProvider(provider dns.Provider) {
|
||||
s.dnsProvider = provider
|
||||
}
|
||||
|
||||
// SetDNSProviderChangedCallback sets the callback for when DNS settings change.
|
||||
func (s *Server) SetDNSProviderChangedCallback(fn DNSProviderChangedFunc) {
|
||||
s.onDNSProviderChanged = fn
|
||||
}
|
||||
|
||||
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
|
||||
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
||||
// Decrypt the OIDC client secret if it's encrypted.
|
||||
@@ -251,6 +269,13 @@ func (s *Server) Router() chi.Router {
|
||||
r.Put("/settings", s.updateSettings)
|
||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
||||
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
|
||||
|
||||
// DNS management endpoints.
|
||||
r.Post("/settings/dns/test", s.testDNSConnection)
|
||||
r.Get("/settings/dns/zones", s.listDNSZones)
|
||||
r.Get("/dns/records", s.listDNSRecords)
|
||||
r.Post("/dns/sync", s.syncDNSRecords)
|
||||
r.Delete("/dns/records/{fqdn}", s.deleteDNSRecord)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+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