package proxy import ( "context" "errors" "fmt" "log/slog" "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/store" ) // Manager handles the lifecycle of standalone proxy hosts. type Manager struct { store *store.Store npm *npm.Client dns dns.Provider // nil when wildcard DNS is active } // NewManager creates a new proxy manager. func NewManager(st *store.Store, npmClient *npm.Client) *Manager { return &Manager{ store: st, npm: npmClient, } } // 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"` 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 an NPM proxy host, 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) } // Build NPM proxy host config. config := npm.ProxyHostConfig{ DomainNames: []string{req.Domain}, ForwardScheme: "http", ForwardHost: req.DestinationURL, ForwardPort: req.DestinationPort, CertificateID: settings.SSLCertificateID, SSLForced: settings.SSLCertificateID > 0, BlockExploits: true, AllowWebsocket: true, HTTP2Support: true, HSTSEnabled: settings.SSLCertificateID > 0, Locations: []any{}, } // Create NPM proxy host. npmHost, err := m.npm.CreateProxyHost(ctx, config) if err != nil { return store.StandaloneProxy{}, fmt.Errorf("create NPM proxy host: %w", err) } slog.Info("created NPM proxy host for standalone proxy", "domain", req.Domain, "npm_proxy_id", npmHost.ID) // Save to store. proxy, err := m.store.CreateStandaloneProxy(store.StandaloneProxy{ Domain: req.Domain, DestinationURL: req.DestinationURL, DestinationPort: req.DestinationPort, SSLCertificateID: settings.SSLCertificateID, NpmProxyID: npmHost.ID, HealthStatus: "unknown", }) if err != nil { // Best effort: clean up the NPM host if store insert fails. if delErr := m.npm.DeleteProxyHost(ctx, npmHost.ID); delErr != nil { slog.Error("failed to clean up NPM proxy host after store error", "npm_proxy_id", npmHost.ID, "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 NPM proxy host, 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 NPM proxy host. config := npm.ProxyHostConfig{ DomainNames: []string{req.Domain}, ForwardScheme: "http", ForwardHost: req.DestinationURL, ForwardPort: req.DestinationPort, CertificateID: settings.SSLCertificateID, SSLForced: settings.SSLCertificateID > 0, BlockExploits: true, AllowWebsocket: true, HTTP2Support: true, HSTSEnabled: settings.SSLCertificateID > 0, Locations: []any{}, } if _, err := m.npm.UpdateProxyHost(ctx, existing.NpmProxyID, config); err != nil { return store.StandaloneProxy{}, fmt.Errorf("update NPM proxy host: %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 NPM proxy host and deletes from the store. func (m *Manager) DeleteProxy(ctx context.Context, id string) error { proxy, err := m.store.GetStandaloneProxy(id) if err != nil { return fmt.Errorf("get proxy: %w", err) } // Delete NPM proxy host. if proxy.NpmProxyID > 0 { if err := m.npm.DeleteProxyHost(ctx, proxy.NpmProxyID); err != nil { slog.Warn("failed to delete NPM proxy host (continuing with store deletion)", "npm_proxy_id", proxy.NpmProxyID, "error", err) } } // Remove DNS record. m.removeDNS(ctx, proxy.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 npm_proxy_id > 0. 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.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) { 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 { 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) }