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:
2026-04-02 14:49:21 +03:00
parent c9d4895ee3
commit c730cfaa45
46 changed files with 2429 additions and 1260 deletions
+365
View File
@@ -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
}
+25
View File
@@ -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
View File
@@ -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)
}
+4
View File
@@ -156,6 +156,10 @@ func (d *Deployer) blueGreenDeploy(
inst.NpmProxyID = npmProxyID
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
// Create/update DNS record for the green instance.
fqdn := subdomain + "." + settings.Domain
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
} else {
d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info")
}
+90
View File
@@ -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 {
+12
View File
@@ -42,6 +42,18 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st
}
}
// Clean up DNS record if the instance had a subdomain.
if instanceID != "" {
inst, err := d.store.GetInstanceByID(instanceID)
if err == nil && inst.Subdomain != "" {
settings, _ := d.store.GetSettings()
if settings.Domain != "" {
fqdn := inst.Subdomain + "." + settings.Domain
d.removeDNS(ctx, fqdn, deployID)
}
}
}
// Update instance status to failed if it was created.
if instanceID != "" {
if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil {
+367
View File
@@ -0,0 +1,367 @@
package dns
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const cfBaseURL = "https://api.cloudflare.com/client/v4"
// Cloudflare implements the Provider interface using the Cloudflare API v4.
type Cloudflare struct {
token string
zoneID string
client *http.Client
}
// NewCloudflare creates a new Cloudflare DNS provider.
// token is required. zoneID can be empty for ListZones/TestConnection calls.
func NewCloudflare(token, zoneID string) (*Cloudflare, error) {
if token == "" {
return nil, fmt.Errorf("cloudflare API token is required")
}
return &Cloudflare{
token: token,
zoneID: zoneID,
client: &http.Client{Timeout: 30 * time.Second},
}, nil
}
// --- Provider interface ---
// EnsureRecord creates or updates an A record for the given FQDN.
func (c *Cloudflare) EnsureRecord(ctx context.Context, fqdn, ip string) (string, error) {
if c.zoneID == "" {
return "", fmt.Errorf("zone ID is required for DNS operations")
}
// Check if a record already exists.
existing, err := c.findRecord(ctx, fqdn)
if err != nil {
return "", fmt.Errorf("find existing record: %w", err)
}
if existing != nil {
// Record exists — update if IP differs.
if existing.Content == ip {
return existing.ID, nil // already correct, no-op
}
updated, err := c.updateRecord(ctx, existing.ID, fqdn, ip)
if err != nil {
return "", fmt.Errorf("update record: %w", err)
}
return updated.ID, nil
}
// Record doesn't exist — create it.
created, err := c.createRecord(ctx, fqdn, ip)
if err != nil {
return "", fmt.Errorf("create record: %w", err)
}
return created.ID, nil
}
// DeleteRecord removes an A record by FQDN. Returns nil if not found.
func (c *Cloudflare) DeleteRecord(ctx context.Context, fqdn string) error {
if c.zoneID == "" {
return fmt.Errorf("zone ID is required for DNS operations")
}
existing, err := c.findRecord(ctx, fqdn)
if err != nil {
return fmt.Errorf("find record: %w", err)
}
if existing == nil {
return nil // doesn't exist, nothing to delete
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, existing.ID)
if _, err := c.doRequest(ctx, http.MethodDelete, endpoint, nil); err != nil {
return fmt.Errorf("delete record: %w", err)
}
return nil
}
// ListRecords returns all A records in the zone.
func (c *Cloudflare) ListRecords(ctx context.Context) ([]Record, error) {
if c.zoneID == "" {
return nil, fmt.Errorf("zone ID is required for DNS operations")
}
var allRecords []Record
page := 1
for {
endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&page=%d&per_page=100", cfBaseURL, c.zoneID, page)
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
var resp cfListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode list response: %w", err)
}
for _, r := range resp.Result {
allRecords = append(allRecords, Record{
ID: r.ID,
FQDN: r.Name,
Type: r.Type,
Content: r.Content,
TTL: r.TTL,
Proxied: r.Proxied,
})
}
if page >= resp.ResultInfo.TotalPages {
break
}
page++
}
return allRecords, nil
}
// TestConnection verifies the API token is valid.
func (c *Cloudflare) TestConnection(ctx context.Context) error {
endpoint := cfBaseURL + "/user/tokens/verify"
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("verify token: %w", err)
}
var resp cfBaseResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("decode verify response: %w", err)
}
if !resp.Success {
return fmt.Errorf("token verification failed: %s", formatErrors(resp.Errors))
}
return nil
}
// --- Additional methods (not part of Provider interface) ---
// ListZones returns all zones accessible by the token.
func (c *Cloudflare) ListZones(ctx context.Context) ([]Zone, error) {
var allZones []Zone
page := 1
for {
endpoint := fmt.Sprintf("%s/zones?page=%d&per_page=50&status=active", cfBaseURL, page)
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("list zones: %w", err)
}
var resp cfZonesResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode zones response: %w", err)
}
for _, z := range resp.Result {
allZones = append(allZones, Zone{
ID: z.ID,
Name: z.Name,
})
}
if page >= resp.ResultInfo.TotalPages {
break
}
page++
}
return allZones, nil
}
// --- Internal helpers ---
func (c *Cloudflare) findRecord(ctx context.Context, fqdn string) (*cfDNSRecord, error) {
endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&name=%s",
cfBaseURL, c.zoneID, url.QueryEscape(fqdn))
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
var resp cfListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode find response: %w", err)
}
if len(resp.Result) == 0 {
return nil, nil
}
return &resp.Result[0], nil
}
func (c *Cloudflare) createRecord(ctx context.Context, fqdn, ip string) (*cfDNSRecord, error) {
payload := cfDNSRecordRequest{
Type: "A",
Name: fqdn,
Content: ip,
TTL: 1, // auto
Proxied: false,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal create payload: %w", err)
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records", cfBaseURL, c.zoneID)
body, err := c.doRequest(ctx, http.MethodPost, endpoint, data)
if err != nil {
return nil, err
}
var resp cfSingleResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode create response: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("create failed: %s", formatErrors(resp.Errors))
}
return &resp.Result, nil
}
func (c *Cloudflare) updateRecord(ctx context.Context, recordID, fqdn, ip string) (*cfDNSRecord, error) {
payload := cfDNSRecordRequest{
Type: "A",
Name: fqdn,
Content: ip,
TTL: 1,
Proxied: false,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal update payload: %w", err)
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, recordID)
body, err := c.doRequest(ctx, http.MethodPut, endpoint, data)
if err != nil {
return nil, err
}
var resp cfSingleResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode update response: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("update failed: %s", formatErrors(resp.Errors))
}
return &resp.Result, nil
}
func (c *Cloudflare) doRequest(ctx context.Context, method, endpoint string, payload []byte) ([]byte, error) {
var bodyReader io.Reader
if payload != nil {
bodyReader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
var errResp cfBaseResponse
if json.Unmarshal(body, &errResp) == nil && len(errResp.Errors) > 0 {
return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, formatErrors(errResp.Errors))
}
return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, string(body))
}
return body, nil
}
// --- Cloudflare API response types ---
type cfBaseResponse struct {
Success bool `json:"success"`
Errors []cfError `json:"errors"`
}
type cfError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type cfDNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfDNSRecordRequest struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfResultInfo struct {
TotalPages int `json:"total_pages"`
}
type cfListResponse struct {
cfBaseResponse
Result []cfDNSRecord `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
}
type cfSingleResponse struct {
cfBaseResponse
Result cfDNSRecord `json:"result"`
}
type cfZone struct {
ID string `json:"id"`
Name string `json:"name"`
}
type cfZonesResponse struct {
cfBaseResponse
Result []cfZone `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
}
func formatErrors(errs []cfError) string {
if len(errs) == 0 {
return "unknown error"
}
msg := errs[0].Message
for _, e := range errs[1:] {
msg += "; " + e.Message
}
return msg
}
+22
View File
@@ -0,0 +1,22 @@
package dns
import "fmt"
// Config holds configuration for creating a DNS provider.
type Config struct {
Token string
ZoneID string
}
// NewProvider creates a DNS provider by name.
// Returns nil, nil when providerName is empty (wildcard DNS mode).
func NewProvider(providerName string, cfg Config) (Provider, error) {
switch providerName {
case "":
return nil, nil
case "cloudflare":
return NewCloudflare(cfg.Token, cfg.ZoneID)
default:
return nil, fmt.Errorf("unsupported DNS provider: %s", providerName)
}
}
+34
View File
@@ -0,0 +1,34 @@
package dns
import "context"
// Record represents a DNS record from a provider.
type Record struct {
ID string `json:"id"`
FQDN string `json:"fqdn"`
Type string `json:"type"`
Content string `json:"content"` // IP address for A records
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
// Zone represents a DNS zone from a provider.
type Zone struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Provider is the interface for DNS record management.
type Provider interface {
// EnsureRecord creates an A record if it doesn't exist, or updates it if the IP differs.
EnsureRecord(ctx context.Context, fqdn, ip string) (recordID string, err error)
// DeleteRecord removes an A record by FQDN. No error if it doesn't exist.
DeleteRecord(ctx context.Context, fqdn string) error
// ListRecords returns all A records in the zone.
ListRecords(ctx context.Context) ([]Record, error)
// TestConnection verifies that the provider credentials are valid.
TestConnection(ctx context.Context) error
}
+69
View File
@@ -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 {
+123
View File
@@ -0,0 +1,123 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateDNSRecord inserts a new DNS record tracking entry.
func (s *Store) CreateDNSRecord(rec DNSRecord) (DNSRecord, error) {
if rec.ID == "" {
rec.ID = uuid.New().String()
}
now := Now()
rec.CreatedAt = now
rec.UpdatedAt = now
_, err := s.db.Exec(
`INSERT INTO dns_records (id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
rec.ID, rec.FQDN, rec.ProviderRecordID, rec.ConsumerType, rec.ConsumerID, rec.CreatedAt, rec.UpdatedAt,
)
if err != nil {
return DNSRecord{}, fmt.Errorf("insert dns_record: %w", err)
}
return rec, nil
}
// GetDNSRecordByFQDN returns a DNS record by its FQDN.
func (s *Store) GetDNSRecordByFQDN(fqdn string) (DNSRecord, error) {
var rec DNSRecord
err := s.db.QueryRow(
`SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at
FROM dns_records WHERE fqdn = ?`, fqdn,
).Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return DNSRecord{}, fmt.Errorf("dns record %s: %w", fqdn, ErrNotFound)
}
if err != nil {
return DNSRecord{}, fmt.Errorf("query dns_record: %w", err)
}
return rec, nil
}
// ListDNSRecords returns all tracked DNS records.
func (s *Store) ListDNSRecords() ([]DNSRecord, error) {
rows, err := s.db.Query(
`SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at
FROM dns_records ORDER BY fqdn`,
)
if err != nil {
return nil, fmt.Errorf("query dns_records: %w", err)
}
defer rows.Close()
var records []DNSRecord
for rows.Next() {
var rec DNSRecord
if err := rows.Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan dns_record: %w", err)
}
records = append(records, rec)
}
return records, rows.Err()
}
// GetDNSRecordsByConsumer returns all DNS records for a specific consumer.
func (s *Store) GetDNSRecordsByConsumer(consumerType, consumerID string) ([]DNSRecord, error) {
rows, err := s.db.Query(
`SELECT id, fqdn, provider_record_id, consumer_type, consumer_id, created_at, updated_at
FROM dns_records WHERE consumer_type = ? AND consumer_id = ? ORDER BY fqdn`,
consumerType, consumerID,
)
if err != nil {
return nil, fmt.Errorf("query dns_records by consumer: %w", err)
}
defer rows.Close()
var records []DNSRecord
for rows.Next() {
var rec DNSRecord
if err := rows.Scan(&rec.ID, &rec.FQDN, &rec.ProviderRecordID, &rec.ConsumerType, &rec.ConsumerID, &rec.CreatedAt, &rec.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan dns_record: %w", err)
}
records = append(records, rec)
}
return records, rows.Err()
}
// UpdateDNSRecordProviderID updates the provider record ID for an existing DNS record.
func (s *Store) UpdateDNSRecordProviderID(fqdn, providerRecordID string) error {
_, err := s.db.Exec(
`UPDATE dns_records SET provider_record_id = ?, updated_at = ? WHERE fqdn = ?`,
providerRecordID, Now(), fqdn,
)
if err != nil {
return fmt.Errorf("update dns_record provider_id: %w", err)
}
return nil
}
// DeleteDNSRecord removes a DNS record by FQDN.
func (s *Store) DeleteDNSRecord(fqdn string) error {
_, err := s.db.Exec(`DELETE FROM dns_records WHERE fqdn = ?`, fqdn)
if err != nil {
return fmt.Errorf("delete dns_record: %w", err)
}
return nil
}
// DeleteDNSRecordsByConsumer removes all DNS records for a specific consumer.
func (s *Store) DeleteDNSRecordsByConsumer(consumerType, consumerID string) error {
_, err := s.db.Exec(
`DELETE FROM dns_records WHERE consumer_type = ? AND consumer_id = ?`,
consumerType, consumerID,
)
if err != nil {
return fmt.Errorf("delete dns_records by consumer: %w", err)
}
return nil
}
+15
View File
@@ -58,9 +58,24 @@ type Settings struct {
SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
UpdatedAt string `json:"updated_at"`
}
// DNSRecord tracks a DNS record managed by the application.
type DNSRecord struct {
ID string `json:"id"`
FQDN string `json:"fqdn"`
ProviderRecordID string `json:"provider_record_id"`
ConsumerType string `json:"consumer_type"` // "instance" or "standalone"
ConsumerID string `json:"consumer_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Instance represents a running (or stopped) container for a project stage.
type Instance struct {
ID string `json:"id"`
+14 -4
View File
@@ -7,36 +7,46 @@ import (
// GetSettings returns the global settings (single-row pattern, always row id=1).
func (s *Store) GetSettings() (Settings, error) {
var st Settings
var wildcardDNS int
err := s.db.QueryRow(
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
base_volume_path, ssl_certificate_id, stale_threshold_days,
allowed_volume_paths, updated_at
allowed_volume_paths, wildcard_dns, dns_provider,
cloudflare_api_token, cloudflare_zone_id, updated_at
FROM settings WHERE id = 1`,
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval,
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
&st.AllowedVolumePaths, &st.UpdatedAt)
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
&st.CloudflareAPIToken, &st.CloudflareZoneID, &st.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("query settings: %w", err)
}
st.WildcardDNS = wildcardDNS != 0
return st, nil
}
// UpdateSettings upserts the global settings row.
func (s *Store) UpdateSettings(st Settings) error {
st.UpdatedAt = Now()
wildcardDNS := 0
if st.WildcardDNS {
wildcardDNS = 1
}
_, err := s.db.Exec(
`UPDATE settings SET
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?,
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
allowed_volume_paths=?, updated_at=?
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
cloudflare_api_token=?, cloudflare_zone_id=?, updated_at=?
WHERE id = 1`,
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval,
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
st.AllowedVolumePaths, st.UpdatedAt,
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
st.CloudflareAPIToken, st.CloudflareZoneID, st.UpdatedAt,
)
if err != nil {
return fmt.Errorf("update settings: %w", err)
+16
View File
@@ -90,6 +90,11 @@ func (s *Store) runMigrations() error {
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
// Add DNS management fields to settings (2026-04-02).
`ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
}
for _, m := range migrations {
@@ -110,6 +115,7 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
}
for _, idx := range indexes {
if _, err := s.db.Exec(idx); err != nil {
@@ -297,6 +303,16 @@ CREATE TABLE IF NOT EXISTS standalone_proxies (
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS dns_records (
id TEXT PRIMARY KEY,
fqdn TEXT NOT NULL UNIQUE,
provider_record_id TEXT NOT NULL DEFAULT '',
consumer_type TEXT NOT NULL DEFAULT '',
consumer_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
// Now returns the current time formatted for SQLite storage.