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:
@@ -10,6 +10,7 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"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/health"
|
||||
@@ -32,6 +33,7 @@ type Deployer struct {
|
||||
notifier *notify.Notifier
|
||||
eventBus EventPublisher
|
||||
encKey [32]byte
|
||||
dns dns.Provider // nil when wildcard DNS is active
|
||||
|
||||
// Graceful shutdown: tracks in-progress deploys.
|
||||
activeWg sync.WaitGroup
|
||||
@@ -64,6 +66,12 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
|
||||
// Pass nil to disable DNS management (wildcard DNS mode).
|
||||
func (d *Deployer) SetDNSProvider(provider dns.Provider) {
|
||||
d.dns = provider
|
||||
}
|
||||
|
||||
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
|
||||
func (d *Deployer) Drain() {
|
||||
d.shuttingDown.Store(true)
|
||||
@@ -357,6 +365,10 @@ func (d *Deployer) executeDeploy(
|
||||
if err := d.store.UpdateInstance(inst); err != nil {
|
||||
slog.Warn("update instance with proxy ID", "error", err)
|
||||
}
|
||||
|
||||
// Create DNS record for this instance.
|
||||
fqdn := subdomain + "." + settings.Domain
|
||||
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
|
||||
} else {
|
||||
d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info")
|
||||
inst.Subdomain = subdomain
|
||||
@@ -526,6 +538,12 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett
|
||||
} else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil {
|
||||
slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr)
|
||||
}
|
||||
|
||||
// Remove DNS record for this instance.
|
||||
if inst.Subdomain != "" && settings.Domain != "" {
|
||||
fqdn := inst.Subdomain + "." + settings.Domain
|
||||
d.removeDNS(ctx, fqdn, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete instance record.
|
||||
@@ -724,6 +742,78 @@ func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status
|
||||
}
|
||||
}
|
||||
|
||||
// ensureDNS creates or updates a DNS record for the given FQDN. Best-effort: logs warnings on failure.
|
||||
func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID, deployID string) {
|
||||
if d.dns == nil {
|
||||
return
|
||||
}
|
||||
settings, err := d.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", "fqdn", fqdn)
|
||||
return
|
||||
}
|
||||
|
||||
recordID, err := d.dns.EnsureRecord(ctx, fqdn, settings.ServerIP)
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("DNS: failed to create/update record for %s: %v", fqdn, err)
|
||||
slog.Warn(msg)
|
||||
if deployID != "" {
|
||||
d.logDeploy(deployID, msg, "warn")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Track the record locally.
|
||||
if _, err := d.store.CreateDNSRecord(store.DNSRecord{
|
||||
FQDN: fqdn,
|
||||
ProviderRecordID: recordID,
|
||||
ConsumerType: consumerType,
|
||||
ConsumerID: consumerID,
|
||||
}); err != nil {
|
||||
// May already exist — try updating.
|
||||
if updateErr := d.store.UpdateDNSRecordProviderID(fqdn, recordID); updateErr != nil {
|
||||
slog.Warn("dns: failed to track record", "fqdn", fqdn, "error", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
logMsg := fmt.Sprintf("DNS: record ensured for %s", fqdn)
|
||||
slog.Info(logMsg)
|
||||
if deployID != "" {
|
||||
d.logDeploy(deployID, logMsg, "info")
|
||||
}
|
||||
}
|
||||
|
||||
// removeDNS deletes a DNS record for the given FQDN. Best-effort: logs warnings on failure.
|
||||
func (d *Deployer) removeDNS(ctx context.Context, fqdn, deployID string) {
|
||||
if d.dns == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.dns.DeleteRecord(ctx, fqdn); err != nil {
|
||||
msg := fmt.Sprintf("DNS: failed to delete record for %s: %v", fqdn, err)
|
||||
slog.Warn(msg)
|
||||
if deployID != "" {
|
||||
d.logDeploy(deployID, msg, "warn")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Remove local tracking.
|
||||
if err := d.store.DeleteDNSRecord(fqdn); err != nil {
|
||||
slog.Warn("dns: failed to remove tracking record", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
|
||||
logMsg := fmt.Sprintf("DNS: record deleted for %s", fqdn)
|
||||
slog.Info(logMsg)
|
||||
if deployID != "" {
|
||||
d.logDeploy(deployID, logMsg, "info")
|
||||
}
|
||||
}
|
||||
|
||||
// truncateID safely truncates a Docker ID to 12 characters for display.
|
||||
func truncateID(id string) string {
|
||||
if len(id) > 12 {
|
||||
|
||||
Reference in New Issue
Block a user