7d6719da12
Replace direct npm.Client usage throughout the codebase with the proxy.Provider interface, enabling pluggable proxy backends. The deployer, API layer, and proxy manager now use provider-agnostic route management (ConfigureRoute/DeleteRoute) instead of NPM-specific API calls. Adds ProxyRouteID (string) to Instance model and ProxyProvider setting to Settings, with SQLite migrations for backward compatibility.
371 lines
11 KiB
Go
371 lines
11 KiB
Go
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)
|
|
}
|