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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user