package proxy import ( "context" "errors" "fmt" "log/slog" "sync" "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/store" ) // Manager handles the lifecycle of standalone proxy hosts. type Manager struct { store *store.Store provider Provider dnsMu sync.RWMutex dns dns.Provider // nil when wildcard DNS is active } // NewManager creates a new proxy manager. func NewManager(st *store.Store, provider Provider) *Manager { return &Manager{ store: st, provider: provider, } } // SetDNSProvider sets the DNS provider for managing DNS records. func (m *Manager) SetDNSProvider(provider dns.Provider) { m.dnsMu.Lock() defer m.dnsMu.Unlock() m.dns = provider } // getDNS returns the current DNS provider under read lock. func (m *Manager) getDNS() dns.Provider { m.dnsMu.RLock() defer m.dnsMu.RUnlock() return m.dns } // CreateProxyRequest is the input for creating a standalone proxy. type CreateProxyRequest struct { Domain string `json:"domain"` DestinationURL string `json:"destination_url"` DestinationPort int `json:"destination_port"` } // UpdateProxyRequest is the input for updating a standalone proxy. type UpdateProxyRequest struct { Domain string `json:"domain"` DestinationURL string `json:"destination_url"` DestinationPort int `json:"destination_port"` } // ProxyView is a unified view of both standalone and deploy-managed proxies. type ProxyView struct { ID string `json:"id"` Domain string `json:"domain"` Destination string `json:"destination"` Type string `json:"type"` // "standalone" or "managed" ProjectName string `json:"project_name,omitempty"` StageName string `json:"stage_name,omitempty"` HealthStatus string `json:"health_status"` SSLEnabled bool `json:"ssl_enabled"` NpmProxyID int `json:"npm_proxy_id"` CreatedAt string `json:"created_at"` } // CreateProxy validates the destination, creates a proxy route via the provider, and saves to the store. func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (store.StandaloneProxy, error) { // Validate destination. result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort) if !result.Valid { return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result)) } // Load settings for SSL certificate and domain. settings, err := m.store.GetSettings() if err != nil { return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err) } // Create proxy route via provider. routeID, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{ SSLCertificateID: settings.SSLCertificateID, }) if err != nil { return store.StandaloneProxy{}, fmt.Errorf("create proxy route: %w", err) } slog.Info("created proxy route for standalone proxy", "domain", req.Domain, "route_id", routeID, "provider", m.provider.Name()) // Save to store. proxy, err := m.store.CreateStandaloneProxy(store.StandaloneProxy{ Domain: req.Domain, DestinationURL: req.DestinationURL, DestinationPort: req.DestinationPort, SSLCertificateID: settings.SSLCertificateID, HealthStatus: "unknown", }) if err != nil { // Best effort: clean up the proxy route if store insert fails. if delErr := m.provider.DeleteRoute(ctx, routeID); delErr != nil { slog.Error("failed to clean up proxy route after store error", "route_id", routeID, "error", delErr) } 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 } // UpdateProxy re-validates the destination, updates the proxy route via the provider, and updates the store. func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyRequest) (store.StandaloneProxy, error) { existing, err := m.store.GetStandaloneProxy(id) if err != nil { return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err) } // Validate new destination. result := ValidateDestination(ctx, req.DestinationURL, req.DestinationPort) if !result.Valid { return store.StandaloneProxy{}, fmt.Errorf("destination validation failed: %s", lastFailedStep(result)) } // Load settings for SSL certificate. settings, err := m.store.GetSettings() if err != nil { return store.StandaloneProxy{}, fmt.Errorf("get settings: %w", err) } // Update proxy route via provider (ConfigureRoute handles create-or-update). if _, err := m.provider.ConfigureRoute(ctx, req.Domain, req.DestinationURL, req.DestinationPort, RouteOptions{ SSLCertificateID: settings.SSLCertificateID, }); err != nil { return store.StandaloneProxy{}, fmt.Errorf("update proxy route: %w", err) } // Update store. updated := existing updated.Domain = req.Domain updated.DestinationURL = req.DestinationURL updated.DestinationPort = req.DestinationPort updated.SSLCertificateID = settings.SSLCertificateID if err := m.store.UpdateStandaloneProxy(updated); err != nil { 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) } // DeleteProxy removes the proxy route via the provider and deletes from the store. func (m *Manager) DeleteProxy(ctx context.Context, id string) error { p, err := m.store.GetStandaloneProxy(id) if err != nil { return fmt.Errorf("get proxy: %w", err) } // Delete proxy route via provider using the NpmProxyID as a string route ID. if p.NpmProxyID > 0 { routeID := fmt.Sprintf("%d", p.NpmProxyID) if err := m.provider.DeleteRoute(ctx, routeID); err != nil { slog.Warn("failed to delete proxy route (continuing with store deletion)", "route_id", routeID, "error", err) } } // Remove DNS record. m.removeDNS(ctx, p.Domain) if err := m.store.DeleteStandaloneProxy(id); err != nil { return fmt.Errorf("delete standalone proxy: %w", err) } return nil } // GetProxy returns a single standalone proxy by ID. func (m *Manager) GetProxy(id string) (store.StandaloneProxy, error) { proxy, err := m.store.GetStandaloneProxy(id) if err != nil { return store.StandaloneProxy{}, fmt.Errorf("get proxy: %w", err) } return proxy, nil } // ListProxies returns all standalone proxies. func (m *Manager) ListProxies() ([]store.StandaloneProxy, error) { proxies, err := m.store.ListStandaloneProxies() if err != nil { return nil, fmt.Errorf("list proxies: %w", err) } return proxies, nil } // ListAllProxies returns a merged view of standalone and deploy-managed proxies. func (m *Manager) ListAllProxies() ([]ProxyView, error) { views := []ProxyView{} // Standalone proxies. standalones, err := m.store.ListStandaloneProxies() if err != nil { return nil, fmt.Errorf("list standalone proxies: %w", err) } for _, p := range standalones { views = append(views, ProxyView{ ID: p.ID, Domain: p.Domain, Destination: fmt.Sprintf("%s:%d", p.DestinationURL, p.DestinationPort), Type: "standalone", HealthStatus: p.HealthStatus, SSLEnabled: p.SSLCertificateID > 0, NpmProxyID: p.NpmProxyID, CreatedAt: p.CreatedAt, }) } // Deploy-managed proxies: instances with a proxy route configured. instances, err := m.store.ListAllInstances() if err != nil { return nil, fmt.Errorf("list instances: %w", err) } // Pre-load project and stage names to avoid N+1 queries. allProjects, _ := m.store.GetAllProjects() projectNames := make(map[string]string, len(allProjects)) for _, p := range allProjects { projectNames[p.ID] = p.Name } stageNames := make(map[string]string) for _, p := range allProjects { stages, _ := m.store.GetStagesByProjectID(p.ID) for _, s := range stages { stageNames[s.ID] = s.Name } } for _, inst := range instances { if inst.ProxyRouteID == "" && inst.NpmProxyID <= 0 { continue } projectName := projectNames[inst.ProjectID] if projectName == "" { projectName = inst.ProjectID } stageName := stageNames[inst.StageID] if stageName == "" { stageName = inst.StageID } cid := inst.ContainerID if len(cid) > 12 { cid = cid[:12] } destination := fmt.Sprintf("%s:%d", cid, inst.Port) if inst.Subdomain != "" { destination = fmt.Sprintf("%s:%d", inst.Subdomain, inst.Port) } healthStatus := "unknown" if inst.Status == "running" { healthStatus = "healthy" } else if inst.Status == "stopped" || inst.Status == "failed" { healthStatus = "unhealthy" } views = append(views, ProxyView{ ID: inst.ID, Domain: inst.Subdomain, Destination: destination, Type: "managed", ProjectName: projectName, StageName: stageName, HealthStatus: healthStatus, SSLEnabled: true, // managed proxies always get SSL from settings NpmProxyID: inst.NpmProxyID, CreatedAt: inst.CreatedAt, }) } 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) { dnsProvider := m.getDNS() if dnsProvider == 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 := dnsProvider.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) { dnsProvider := m.getDNS() if dnsProvider == nil { return } if err := dnsProvider.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 { if !step.Passed { msg := step.Message if step.Hint != "" { msg += " — " + step.Hint } return msg } } return "unknown validation failure" } // IsNotFound checks if an error wraps store.ErrNotFound. func IsNotFound(err error) bool { return errors.Is(err, store.ErrNotFound) }