package api import ( "context" "fmt" "log/slog" "net/http" "path/filepath" "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" "github.com/alexei/docker-watcher/internal/webhook" ) // settingsRequest is the expected JSON body for updating settings. type settingsRequest struct { Domain string `json:"domain"` ServerIP string `json:"server_ip"` Network string `json:"network"` SubdomainPattern string `json:"subdomain_pattern"` NotificationURL string `json:"notification_url"` NpmURL string `json:"npm_url"` NpmEmail string `json:"npm_email"` NpmPassword string `json:"npm_password"` PollingInterval string `json:"polling_interval"` 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. func (s *Server) getSettings(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 } // 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, "wildcard_dns": settings.WildcardDNS, "dns_provider": settings.DNSProvider, "has_cloudflare_api_token": settings.CloudflareAPIToken != "", "cloudflare_zone_id": settings.CloudflareZoneID, "updated_at": settings.UpdatedAt, }) } // updateSettings handles PUT /api/settings. func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { var req settingsRequest if !decodeJSON(w, r, &req) { return } existing, err := s.store.GetSettings() if err != nil { respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) return } updated := existing if req.Domain != "" { updated.Domain = req.Domain } if req.ServerIP != "" { updated.ServerIP = req.ServerIP } if req.Network != "" { updated.Network = req.Network } if req.SubdomainPattern != "" { updated.SubdomainPattern = req.SubdomainPattern } // Allow clearing notification URL. updated.NotificationURL = req.NotificationURL if req.NpmURL != "" { updated.NpmURL = req.NpmURL } if req.NpmEmail != "" { updated.NpmEmail = req.NpmEmail } if req.NpmPassword != "" { encPassword, err := crypto.Encrypt(s.encKey, req.NpmPassword) if err != nil { respondError(w, http.StatusInternalServerError, "failed to encrypt npm password: "+err.Error()) return } updated.NpmPassword = encPassword } if req.PollingInterval != "" { updated.PollingInterval = req.PollingInterval } sslChanged := false if req.SSLCertificateID != nil && *req.SSLCertificateID != updated.SSLCertificateID { updated.SSLCertificateID = *req.SSLCertificateID sslChanged = true } if req.StaleThresholdDays != nil { if *req.StaleThresholdDays < 1 { respondError(w, http.StatusBadRequest, "stale_threshold_days must be at least 1") return } updated.StaleThresholdDays = *req.StaleThresholdDays } if req.AllowedVolumePaths != nil { // Validate it's valid JSON array of strings. paths, err := volume.ParseAllowedPaths(*req.AllowedVolumePaths) if err != nil { respondError(w, http.StatusBadRequest, "allowed_volume_paths must be a JSON array of strings") return } // Validate each path is absolute. for _, p := range paths { if !filepath.IsAbs(p) { respondError(w, http.StatusBadRequest, "each allowed volume path must be absolute") return } } updated.AllowedVolumePaths = *req.AllowedVolumePaths _ = 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 } // If SSL cert changed, update all existing NPM proxy hosts in the background. if sslChanged { 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 { oldProvider := s.getDNSProviderLocked() go s.handleDNSSettingsChange(oldProvider, existing, updated) } respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } // getWebhookURL handles GET /api/settings/webhook-url. func (s *Server) getWebhookURL(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 } webhookURL := "" if settings.WebhookSecret != "" && settings.Domain != "" { webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret) } respondJSON(w, http.StatusOK, map[string]string{ "webhook_url": webhookURL, }) } // regenerateWebhookSecret handles POST /api/settings/regenerate. func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) { secret, err := webhook.RegenerateWebhookSecret(s.store) if err != nil { respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error()) return } settings, err := s.store.GetSettings() if err != nil { respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) return } webhookURL := "" if settings.Domain != "" { webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret) } respondJSON(w, http.StatusOK, map[string]string{ "webhook_url": webhookURL, "webhook_secret": secret, }) } // listNpmCertificates handles GET /api/settings/npm-certificates. // It authenticates to NPM using the stored credentials and returns only wildcard certificates. func (s *Server) listNpmCertificates(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.NpmURL == "" || settings.NpmEmail == "" || settings.NpmPassword == "" { respondError(w, http.StatusBadRequest, "NPM credentials not configured") return } npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) if err != nil { respondError(w, http.StatusInternalServerError, "failed to decrypt npm password: "+err.Error()) return } client := npm.New(settings.NpmURL) if err := client.Authenticate(r.Context(), settings.NpmEmail, npmPassword); err != nil { respondError(w, http.StatusBadGateway, "failed to authenticate to NPM: "+err.Error()) return } certs, err := client.ListCertificates(r.Context()) if err != nil { respondError(w, http.StatusBadGateway, "failed to list certificates: "+err.Error()) return } // Filter to wildcard certificates only. var wildcards []npm.Certificate for _, cert := range certs { if isWildcardCert(cert) { wildcards = append(wildcards, cert) } } if wildcards == nil { wildcards = []npm.Certificate{} } respondJSON(w, http.StatusOK, wildcards) } // isWildcardCert returns true if any of the certificate's domain names contains "*". func isWildcardCert(cert npm.Certificate) bool { for _, d := range cert.DomainNames { if strings.Contains(d, "*") { return true } } return false } // reapplySSLToAllProxies updates all existing NPM proxy hosts managed by Docker Watcher // to use the new SSL certificate. Runs in the background after settings change. func (s *Server) reapplySSLToAllProxies(settings store.Settings) { ctx := context.Background() npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) if err != nil { slog.Error("reapply SSL: decrypt npm password", "error", err) return } npmClient := npm.New(settings.NpmURL) if err := npmClient.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { slog.Error("reapply SSL: authenticate to NPM", "error", err) return } // Get all proxy hosts from NPM. hosts, err := npmClient.ListProxyHosts(ctx) if err != nil { slog.Error("reapply SSL: list proxy hosts", "error", err) return } // Get all our managed instances to identify which proxy hosts are ours. projects, err := s.store.GetAllProjects() if err != nil { slog.Error("reapply SSL: get projects", "error", err) return } // Build a set of NPM proxy IDs that belong to our instances. managedProxyIDs := make(map[int]bool) for _, p := range projects { stages, err := s.store.GetStagesByProjectID(p.ID) if err != nil { continue } for _, st := range stages { instances, err := s.store.GetInstancesByStageID(st.ID) if err != nil { continue } for _, inst := range instances { if inst.NpmProxyID > 0 { managedProxyIDs[inst.NpmProxyID] = true } } } } updated := 0 for _, host := range hosts { if !managedProxyIDs[host.ID] { continue } config := npm.ProxyHostConfig{ DomainNames: host.DomainNames, ForwardScheme: host.ForwardScheme, ForwardHost: host.ForwardHost, ForwardPort: host.ForwardPort, BlockExploits: true, AllowWebsocket: true, HTTP2Support: true, Meta: npm.Meta{}, Locations: []any{}, } if settings.SSLCertificateID > 0 { config.CertificateID = settings.SSLCertificateID config.SSLForced = true config.HSTSEnabled = true } else { config.CertificateID = 0 config.SSLForced = false config.HSTSEnabled = false } if _, err := npmClient.UpdateProxyHost(ctx, host.ID, config); err != nil { slog.Warn("reapply SSL: update proxy host failed", "host_id", host.ID, "error", err) continue } updated++ } 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(oldProvider dns.Provider, 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 != "" && oldProvider != 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 := oldProvider.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.SetDNSProvider(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, }) } // dnsZonesRequest is the expected JSON body for listing DNS zones. type dnsZonesRequest struct { Token string `json:"token"` } // listDNSZones handles POST /api/settings/dns/zones. func (s *Server) listDNSZones(w http.ResponseWriter, r *http.Request) { var req dnsZonesRequest if !decodeJSON(w, r, &req) { return } token := req.Token // If no token in body, 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) }