package api import ( "fmt" "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" ) // dnsTargetIP returns the IP to use for DNS A records. // Prefers PublicIP (the proxy/NPM host), falls back to ServerIP. func dnsTargetIP(settings store.Settings) string { if settings.PublicIP != "" { return settings.PublicIP } return dnsTargetIP(settings) } // 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, err := s.computeExpectedFQDNs(settings) if err != nil { respondError(w, http.StatusInternalServerError, "failed to compute expected records: "+err.Error()) return } 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: dnsTargetIP(settings), 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 := dnsTargetIP(settings) 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 } } } 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 p := s.getDNSProviderLocked(); p != nil { return p } 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, err := s.computeExpectedFQDNs(settings) if err != nil { respondError(w, http.StatusInternalServerError, "failed to compute expected records: "+err.Error()) return } // 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, dnsTargetIP(settings)) 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, error) { expected := make(map[string]string) // Instances with proxy enabled. projects, err := s.store.GetAllProjects() if err != nil { return nil, fmt.Errorf("get projects: %w", err) } for _, p := range projects { stages, err := s.store.GetStagesByProjectID(p.ID) if err != nil { slog.Warn("dns: failed to get stages", "project_id", p.ID, "error", err) continue } for _, st := range stages { if !st.EnableProxy { continue } instances, err := s.store.GetInstancesByStageID(st.ID) if err != nil { slog.Warn("dns: failed to get instances", "stage_id", st.ID, "error", err) continue } for _, inst := range instances { if inst.NpmProxyID > 0 && inst.Subdomain != "" && inst.Status == "running" { fqdn := inst.Subdomain + "." + settings.Domain expected[fqdn] = "instance:" + inst.ID } } } } return expected, nil }