package api import ( "context" "log/slog" "net/http" "path/filepath" "strings" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/dns" "github.com/alexei/tinyforge/internal/docker" "github.com/alexei/tinyforge/internal/npm" "github.com/alexei/tinyforge/internal/proxy" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/volume" "github.com/alexei/tinyforge/internal/webhook" ) // settingsRequest is the expected JSON body for updating settings. type settingsRequest struct { Domain string `json:"domain"` ServerIP string `json:"server_ip"` PublicIP string `json:"public_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"` NpmAccessListID *int `json:"npm_access_list_id,omitempty"` ImagePruneThresholdMB *int `json:"image_prune_threshold_mb,omitempty"` NpmRemote *bool `json:"npm_remote,omitempty"` ProxyProvider *string `json:"proxy_provider,omitempty"` TraefikEntrypoint *string `json:"traefik_entrypoint,omitempty"` TraefikCertResolver *string `json:"traefik_cert_resolver,omitempty"` TraefikNetwork *string `json:"traefik_network,omitempty"` TraefikAPIURL *string `json:"traefik_api_url,omitempty"` BackupEnabled *bool `json:"backup_enabled,omitempty"` BackupIntervalHours *int `json:"backup_interval_hours,omitempty"` BackupRetentionCount *int `json:"backup_retention_count,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, "public_ip": settings.PublicIP, "network": settings.Network, "subdomain_pattern": settings.SubdomainPattern, "notification_url": settings.NotificationURL, "npm_url": settings.NpmURL, "npm_email": settings.NpmEmail, "has_npm_password": settings.NpmPassword != "", "npm_remote": settings.NpmRemote, "image_prune_threshold_mb": settings.ImagePruneThresholdMB, "npm_access_list_id": settings.NpmAccessListID, "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, "proxy_provider": settings.ProxyProvider, "traefik_entrypoint": settings.TraefikEntrypoint, "traefik_cert_resolver": settings.TraefikCertResolver, "traefik_network": settings.TraefikNetwork, "traefik_api_url": settings.TraefikAPIURL, "backup_enabled": settings.BackupEnabled, "backup_interval_hours": settings.BackupIntervalHours, "backup_retention_count": settings.BackupRetentionCount, "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.PublicIP != "" { updated.PublicIP = req.PublicIP } 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 } // Proxy provider setting. if req.ProxyProvider != nil { prov := *req.ProxyProvider if prov != "" && prov != "none" && prov != "npm" && prov != "traefik" { respondError(w, http.StatusBadRequest, "proxy_provider must be 'none', 'npm', or 'traefik'") return } updated.ProxyProvider = prov } if req.ImagePruneThresholdMB != nil { updated.ImagePruneThresholdMB = *req.ImagePruneThresholdMB } if req.NpmRemote != nil { updated.NpmRemote = *req.NpmRemote } if req.NpmAccessListID != nil { updated.NpmAccessListID = *req.NpmAccessListID } // Traefik provider settings. if req.TraefikEntrypoint != nil { updated.TraefikEntrypoint = *req.TraefikEntrypoint } if req.TraefikCertResolver != nil { updated.TraefikCertResolver = *req.TraefikCertResolver } if req.TraefikNetwork != nil { updated.TraefikNetwork = *req.TraefikNetwork } if req.TraefikAPIURL != nil { updated.TraefikAPIURL = *req.TraefikAPIURL } // Backup settings. if req.BackupEnabled != nil { updated.BackupEnabled = *req.BackupEnabled } if req.BackupIntervalHours != nil { if *req.BackupIntervalHours < 1 { respondError(w, http.StatusBadRequest, "backup_interval_hours must be at least 1") return } updated.BackupIntervalHours = *req.BackupIntervalHours } if req.BackupRetentionCount != nil { if *req.BackupRetentionCount < 1 { respondError(w, http.StatusBadRequest, "backup_retention_count must be at least 1") return } updated.BackupRetentionCount = *req.BackupRetentionCount } if err := s.store.UpdateSettings(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) return } // If proxy-affecting settings changed, re-sync all proxy routes in the background. proxyChanged := existing.Domain != updated.Domain || existing.ProxyProvider != updated.ProxyProvider || existing.NpmRemote != updated.NpmRemote || existing.NpmAccessListID != updated.NpmAccessListID || sslChanged if proxyChanged { go s.resyncAllProxies(existing, 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) } // Handle backup settings changes. backupChanged := existing.BackupEnabled != updated.BackupEnabled || existing.BackupIntervalHours != updated.BackupIntervalHours if backupChanged && s.onBackupSettingsChanged != nil { s.onBackupSettingsChanged(updated.BackupEnabled, updated.BackupIntervalHours) } 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 } webhookPath := "" if settings.WebhookSecret != "" { webhookPath = "/api/webhook/" + settings.WebhookSecret } respondJSON(w, http.StatusOK, map[string]string{ "webhook_url": webhookPath, }) } // 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 } webhookURL := "/api/webhook/" + 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) } // listNpmAccessLists handles GET /api/settings/npm-access-lists. // It authenticates to NPM using the stored credentials and returns all access lists. func (s *Server) listNpmAccessLists(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 } lists, err := client.ListAccessLists(r.Context()) if err != nil { respondError(w, http.StatusBadGateway, "failed to list access lists: "+err.Error()) return } if lists == nil { lists = []npm.AccessList{} } respondJSON(w, http.StatusOK, lists) } // 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 } // createProxyProvider builds a proxy.Provider from the given settings. func (s *Server) createProxyProvider(settings store.Settings) proxy.Provider { switch settings.ProxyProvider { case "npm": if settings.NpmURL == "" || settings.NpmEmail == "" || settings.NpmPassword == "" { slog.Warn("proxy resync: NPM credentials incomplete, falling back to none") return proxy.NewNoneProvider() } npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) if err != nil { slog.Error("proxy resync: decrypt npm password", "error", err) return proxy.NewNoneProvider() } return proxy.NewNpmProvider(npm.New(settings.NpmURL), settings.NpmEmail, npmPassword) case "traefik": return proxy.NewTraefikProvider( settings.TraefikEntrypoint, settings.TraefikCertResolver, settings.TraefikNetwork, settings.TraefikAPIURL, ) default: return proxy.NewNoneProvider() } } // resyncAllProxies re-configures or removes proxy routes for all running instances // when proxy-affecting settings change (domain, SSL cert, proxy provider). // Runs in the background after settings save. func (s *Server) resyncAllProxies(oldSettings, newSettings store.Settings) { ctx := context.Background() // Collect all proxy-enabled instances. routes, err := s.store.ListProxyRoutes(oldSettings.Domain) if err != nil { slog.Error("proxy resync: list routes", "error", err) return } if len(routes) == 0 { slog.Info("proxy resync: no proxy routes to update") return } providerChanged := oldSettings.ProxyProvider != newSettings.ProxyProvider domainChanged := oldSettings.Domain != newSettings.Domain // Step 1: If provider changed, delete old routes from the OLD provider, then switch. if providerChanged { slog.Info("proxy resync: provider changed", "old", oldSettings.ProxyProvider, "new", newSettings.ProxyProvider) oldProvider := s.proxyProvider for _, route := range routes { if route.ProxyRouteID != "" { if err := oldProvider.DeleteRoute(ctx, route.ProxyRouteID); err != nil { slog.Warn("proxy resync: delete old route", "route_id", route.ProxyRouteID, "error", err) } } } // Create and install the new provider. newProvider := s.createProxyProvider(newSettings) s.SetProxyProvider(newProvider) if s.onProxyProviderChanged != nil { s.onProxyProviderChanged(newProvider) } } // Step 2: If new provider is "none", clear all proxy route IDs and we're done. if newSettings.ProxyProvider == "none" { for _, route := range routes { inst, err := s.store.GetInstanceByID(route.InstanceID) if err != nil { continue } inst.ProxyRouteID = "" inst.NpmProxyID = 0 if err := s.store.UpdateInstance(inst); err != nil { slog.Warn("proxy resync: clear route ID", "instance", route.InstanceID, "error", err) } } slog.Info("proxy resync: cleared all proxy routes (provider set to none)", "count", len(routes)) return } // Step 3: Re-create/update routes with the current provider and new settings. updated := 0 for _, route := range routes { if route.Subdomain == "" { continue } fqdn := route.Subdomain + "." + newSettings.Domain // Reconstruct the container name (Docker DNS name) from project/stage/tag. containerName := docker.ContainerName(route.ProjectName, route.StageName, route.ImageTag) routeID, err := s.proxyProvider.ConfigureRoute(ctx, fqdn, containerName, route.Port, proxy.RouteOptions{ SSLCertificateID: newSettings.SSLCertificateID, }) if err != nil { slog.Warn("proxy resync: configure route failed", "domain", fqdn, "instance", route.InstanceID, "error", err) continue } // Update instance with new route ID. inst, err := s.store.GetInstanceByID(route.InstanceID) if err != nil { continue } inst.ProxyRouteID = routeID if domainChanged { // Subdomain stays the same, but the FQDN in external systems changed. slog.Info("proxy resync: domain updated", "instance", route.InstanceID, "domain", fqdn) } if err := s.store.UpdateInstance(inst); err != nil { slog.Warn("proxy resync: update instance", "instance", route.InstanceID, "error", err) } updated++ } slog.Info("proxy resync: completed", "updated", updated, "total", len(routes)) } // 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"` } // testNpmConnection handles POST /api/settings/npm/test. // Tests connectivity and authentication to the NPM API. func (s *Server) testNpmConnection(w http.ResponseWriter, r *http.Request) { var req struct { URL string `json:"npm_url"` Email string `json:"npm_email"` Password string `json:"npm_password"` } if !decodeJSON(w, r, &req) { return } // Use provided values, fall back to stored settings. settings, err := s.store.GetSettings() if err != nil { slog.Error("failed to get settings", "error", err) respondError(w, http.StatusInternalServerError, "internal server error") return } npmURL := req.URL if npmURL == "" { npmURL = settings.NpmURL } if npmURL == "" { respondError(w, http.StatusBadRequest, "NPM URL is required") return } email := req.Email if email == "" { email = settings.NpmEmail } password := req.Password if password == "" && settings.NpmPassword != "" { decrypted, err := crypto.Decrypt(s.encKey, settings.NpmPassword) if err != nil { respondError(w, http.StatusBadRequest, "failed to decrypt stored NPM password") return } password = decrypted } if email == "" || password == "" { respondError(w, http.StatusBadRequest, "NPM email and password are required") return } // Test connectivity. client := npm.New(npmURL) ctx := r.Context() if err := client.Ping(ctx); err != nil { slog.Warn("npm test: ping failed", "url", npmURL, "error", err) respondError(w, http.StatusBadGateway, "Cannot reach NPM at "+npmURL) return } // Test authentication. if err := client.Authenticate(ctx, email, password); err != nil { slog.Warn("npm test: auth failed", "url", npmURL, "error", err) respondError(w, http.StatusBadGateway, "NPM authentication failed — check email and password") return } respondJSON(w, http.StatusOK, map[string]string{"status": "connected"}) } // 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) }