diff --git a/cmd/server/main.go b/cmd/server/main.go index 9064ef2..798ede4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -91,10 +91,31 @@ func main() { os.Exit(1) } - // Initialize NPM client. + // Initialize NPM client (used for NPM-specific endpoints like certificates). npmURL := envOrDefault("NPM_URL", settings.NpmURL) npmClient := npm.New(npmURL) + // Build proxy provider based on settings. + var proxyProvider proxy.Provider + switch settings.ProxyProvider { + case "none": + proxyProvider = proxy.NewNoneProvider() + slog.Info("proxy provider: none") + default: + // Default to NPM for backward compatibility (including "npm" and empty string). + npmPassword := "" + if settings.NpmPassword != "" { + decrypted, err := crypto.Decrypt(encKey, settings.NpmPassword) + if err != nil { + slog.Warn("failed to decrypt NPM password for proxy provider", "error", err) + } else { + npmPassword = decrypted + } + } + proxyProvider = proxy.NewNpmProvider(npmClient, settings.NpmEmail, npmPassword) + slog.Info("proxy provider: npm", "url", npmURL) + } + // Initialize services. healthChecker := health.New() notifier := notify.New() @@ -115,7 +136,7 @@ func main() { }) defer stopLogger() - dep := deployer.New(dockerClient, npmClient, db, healthChecker, notifier, eventBus, encKey) + dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey) // Initialize webhook handler. webhookHandler := webhook.NewHandler(db, dep, dockerClient) @@ -144,7 +165,7 @@ func main() { } // Initialize proxy manager and health monitor. - proxyManager := proxy.NewManager(db, npmClient) + proxyManager := proxy.NewManager(db, proxyProvider) proxyHealth := proxy.NewHealthMonitor(db, eventBus) if err := proxyHealth.Start("5m"); err != nil { slog.Warn("failed to start proxy health monitor", "error", err) @@ -260,7 +281,7 @@ func main() { scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours) // Build API server. - apiServer := api.NewServer(db, dockerClient, npmClient, dep, webhookHandler, eventBus, encKey) + apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey) apiServer.SetStaleScanner(staleScanner) apiServer.SetProxyManager(proxyManager) apiServer.SetBackupEngine(backupEngine) diff --git a/internal/api/health.go b/internal/api/health.go index 17fc664..823a956 100644 --- a/internal/api/health.go +++ b/internal/api/health.go @@ -38,12 +38,13 @@ func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) { result["docker"] = map[string]any{"connected": true} } - // Check NPM connectivity if configured. - if s.npm != nil { - if err := s.npm.Ping(ctx); err != nil { - result["npm"] = map[string]any{"connected": false, "error": "NPM unreachable"} + // Check proxy provider connectivity. + if s.proxyProvider != nil { + providerName := s.proxyProvider.Name() + if err := s.proxyProvider.Ping(ctx); err != nil { + result["proxy"] = map[string]any{"provider": providerName, "connected": false, "error": providerName + " unreachable"} } else { - result["npm"] = map[string]any{"connected": true} + result["proxy"] = map[string]any{"provider": providerName, "connected": true} } } diff --git a/internal/api/instances.go b/internal/api/instances.go index 47f07a9..edf4a64 100644 --- a/internal/api/instances.go +++ b/internal/api/instances.go @@ -9,7 +9,6 @@ import ( "github.com/go-chi/chi/v5" - "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/store" ) @@ -116,18 +115,10 @@ func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) { } } - // Delete NPM proxy host if it has one. - if inst.NpmProxyID > 0 { - settings, err := s.store.GetSettings() - if err == nil { - npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) - if err == nil { - if authErr := s.npm.Authenticate(r.Context(), settings.NpmEmail, npmPassword); authErr == nil { - if delErr := s.npm.DeleteProxyHost(r.Context(), inst.NpmProxyID); delErr != nil { - slog.Warn("delete proxy host on instance removal", "proxy_id", inst.NpmProxyID, "error", delErr) - } - } - } + // Delete proxy route if it has one. + if inst.ProxyRouteID != "" { + if err := s.proxyProvider.DeleteRoute(r.Context(), inst.ProxyRouteID); err != nil { + slog.Warn("delete proxy route on instance removal", "route_id", inst.ProxyRouteID, "error", err) } } diff --git a/internal/api/router.go b/internal/api/router.go index f1a07e3..8b7a133 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -26,10 +26,11 @@ type DNSProviderChangedFunc func(provider dns.Provider) // Server holds all dependencies for the API layer. type Server struct { - store *store.Store - docker *docker.Client - npm *npm.Client - deployer DeployTriggerer + store *store.Store + docker *docker.Client + npm *npm.Client // optional: only for NPM-specific endpoints (certificates) + proxyProvider proxy.Provider + deployer DeployTriggerer webhook *webhook.Handler eventBus *events.Bus encKey [32]byte @@ -53,6 +54,7 @@ func NewServer( st *store.Store, dockerClient *docker.Client, npmClient *npm.Client, + proxyProvider proxy.Provider, deployer DeployTriggerer, webhookHandler *webhook.Handler, eventBus *events.Bus, @@ -61,14 +63,15 @@ func NewServer( localAuth := auth.NewLocalAuth(encKey) s := &Server{ - store: st, - docker: dockerClient, - npm: npmClient, - deployer: deployer, - webhook: webhookHandler, - eventBus: eventBus, - encKey: encKey, - localAuth: localAuth, + store: st, + docker: dockerClient, + npm: npmClient, + proxyProvider: proxyProvider, + deployer: deployer, + webhook: webhookHandler, + eventBus: eventBus, + encKey: encKey, + localAuth: localAuth, } // Try to initialize OIDC provider from stored settings. diff --git a/internal/api/settings.go b/internal/api/settings.go index e67491f..f7188b3 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -34,6 +34,7 @@ type settingsRequest struct { DNSProvider *string `json:"dns_provider,omitempty"` CloudflareAPIToken string `json:"cloudflare_api_token"` CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"` + ProxyProvider *string `json:"proxy_provider,omitempty"` BackupEnabled *bool `json:"backup_enabled,omitempty"` BackupIntervalHours *int `json:"backup_interval_hours,omitempty"` BackupRetentionCount *int `json:"backup_retention_count,omitempty"` @@ -65,6 +66,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "dns_provider": settings.DNSProvider, "has_cloudflare_api_token": settings.CloudflareAPIToken != "", "cloudflare_zone_id": settings.CloudflareZoneID, + "proxy_provider": settings.ProxyProvider, "backup_enabled": settings.BackupEnabled, "backup_interval_hours": settings.BackupIntervalHours, "backup_retention_count": settings.BackupRetentionCount, @@ -166,6 +168,16 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { updated.CloudflareZoneID = *req.CloudflareZoneID } + // Proxy provider setting. + if req.ProxyProvider != nil { + prov := *req.ProxyProvider + if prov != "" && prov != "none" && prov != "npm" { + respondError(w, http.StatusBadRequest, "proxy_provider must be 'none' or 'npm'") + return + } + updated.ProxyProvider = prov + } + // Backup settings. if req.BackupEnabled != nil { updated.BackupEnabled = *req.BackupEnabled diff --git a/internal/api/stale.go b/internal/api/stale.go index 340041e..10dfa2c 100644 --- a/internal/api/stale.go +++ b/internal/api/stale.go @@ -7,7 +7,6 @@ import ( "github.com/go-chi/chi/v5" - "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/stale" "github.com/alexei/docker-watcher/internal/store" @@ -121,18 +120,10 @@ func (s *Server) cleanupInstance(r *http.Request, inst store.Instance) error { } } - // Delete NPM proxy host if present. - if inst.NpmProxyID > 0 { - settings, err := s.store.GetSettings() - if err == nil { - npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) - if err == nil { - if authErr := s.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr == nil { - if delErr := s.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { - slog.Warn("stale cleanup: delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr) - } - } - } + // Delete proxy route if present. + if inst.ProxyRouteID != "" { + if err := s.proxyProvider.DeleteRoute(ctx, inst.ProxyRouteID); err != nil { + slog.Warn("stale cleanup: delete proxy route", "route_id", inst.ProxyRouteID, "error", err) } } diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index ed43501..3f5a94a 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -24,11 +24,11 @@ func (d *Deployer) blueGreenDeploy( settings store.Settings, deployID string, imageTag string, -) (string, int, string, error) { +) (string, string, string, error) { // Find existing running instance for this stage (the "blue" instance). existingInstances, err := d.store.GetInstancesByStageID(stage.ID) if err != nil { - return "", 0, "", fmt.Errorf("get existing instances: %w", err) + return "", "", "", fmt.Errorf("get existing instances: %w", err) } var blueInstance *store.Instance @@ -49,18 +49,18 @@ func (d *Deployer) blueGreenDeploy( authConfig, err := d.buildRegistryAuth(project) if err != nil { - return "", 0, "", fmt.Errorf("build registry auth: %w", err) + return "", "", "", fmt.Errorf("build registry auth: %w", err) } if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { - return "", 0, "", fmt.Errorf("pull image: %w", err) + return "", "", "", fmt.Errorf("pull image: %w", err) } d.logDeploy(deployID, "Image pulled successfully", "info") // Step 2: Ensure network. networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) if err != nil { - return "", 0, "", fmt.Errorf("ensure network: %w", err) + return "", "", "", fmt.Errorf("ensure network: %w", err) } // Step 3: Create and start green container. @@ -92,7 +92,7 @@ func (d *Deployer) blueGreenDeploy( d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info") containerID, err := d.docker.CreateContainer(ctx, containerCfg) if err != nil { - return "", 0, instanceID, fmt.Errorf("create container: %w", err) + return "", "", instanceID, fmt.Errorf("create container: %w", err) } // Create instance record. @@ -107,7 +107,7 @@ func (d *Deployer) blueGreenDeploy( Port: project.Port, }) if err != nil { - return containerID, 0, instanceID, fmt.Errorf("create instance record: %w", err) + return containerID, "", instanceID, fmt.Errorf("create instance record: %w", err) } instanceID = inst.ID @@ -117,7 +117,7 @@ func (d *Deployer) blueGreenDeploy( d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info") if err := d.docker.StartContainer(ctx, containerID); err != nil { - return containerID, 0, instanceID, fmt.Errorf("start container: %w", err) + return containerID, "", instanceID, fmt.Errorf("start container: %w", err) } if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil { @@ -136,25 +136,25 @@ func (d *Deployer) blueGreenDeploy( d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info") if err := d.health.Check(ctx, healthURL); err != nil { - return containerID, 0, instanceID, fmt.Errorf("health check green: %w", err) + return containerID, "", instanceID, fmt.Errorf("health check green: %w", err) } d.logDeploy(deployID, "Blue-green: green health check passed", "info") } - // Step 5: Swap NPM proxy to green. - var npmProxyID int + // Step 5: Swap proxy to green. + var proxyRouteID string if stage.EnableProxy { if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil { slog.Warn("update deploy status", "error", err) } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) if err != nil { - return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err) + return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err) } - inst.NpmProxyID = npmProxyID + inst.ProxyRouteID = proxyRouteID d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info") // Create/update DNS record for the green instance. @@ -180,5 +180,5 @@ func (d *Deployer) blueGreenDeploy( } } - return containerID, npmProxyID, instanceID, nil + return containerID, proxyRouteID, instanceID, nil } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index e6cd9f7..6a317c5 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -15,7 +15,7 @@ import ( "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/health" "github.com/alexei/docker-watcher/internal/notify" - "github.com/alexei/docker-watcher/internal/npm" + "github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/volume" "github.com/moby/moby/api/types/mount" @@ -27,7 +27,7 @@ import ( // It implements both webhook.DeployTriggerer and registry.DeployTriggerer. type Deployer struct { docker *docker.Client - npm *npm.Client + proxy proxy.Provider store *store.Store health *health.Checker notifier *notify.Notifier @@ -49,7 +49,7 @@ type EventPublisher interface { // New creates a new Deployer with all required dependencies. func New( dockerClient *docker.Client, - npmClient *npm.Client, + proxyProvider proxy.Provider, st *store.Store, checker *health.Checker, notifier *notify.Notifier, @@ -58,7 +58,7 @@ func New( ) *Deployer { return &Deployer{ docker: dockerClient, - npm: npmClient, + proxy: proxyProvider, store: st, health: checker, notifier: notifier, @@ -161,20 +161,20 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s } var containerID string - var npmProxyID int + var proxyRouteID string var instanceID string var deployErr error if stage.MaxInstances == 1 { - containerID, npmProxyID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag) + containerID, proxyRouteID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag) } else { - containerID, npmProxyID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag) + containerID, proxyRouteID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag) } if deployErr != nil { d.logDeploy(deployID, fmt.Sprintf("Deploy failed: %v", deployErr), "error") d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error()) - d.rollback(ctx, deployID, containerID, npmProxyID, instanceID) + d.rollback(ctx, deployID, containerID, proxyRouteID, instanceID) d.notifier.Send(settings.NotificationURL, notify.Event{ Type: "deploy_failure", @@ -250,7 +250,7 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT } // executeDeploy runs the deploy pipeline steps and returns rollback-relevant state. -// It returns (containerID, npmProxyID, instanceID, error). +// It returns (containerID, proxyRouteID, instanceID, error). func (d *Deployer) executeDeploy( ctx context.Context, project store.Project, @@ -258,9 +258,9 @@ func (d *Deployer) executeDeploy( settings store.Settings, deployID string, imageTag string, -) (string, int, string, error) { +) (string, string, string, error) { var containerID string - var npmProxyID int + var proxyRouteID string var instanceID string // Step 1: Pull image. @@ -272,18 +272,18 @@ func (d *Deployer) executeDeploy( authConfig, err := d.buildRegistryAuth(project) if err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("build registry auth: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("build registry auth: %w", err) } if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("pull image: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("pull image: %w", err) } d.logDeploy(deployID, "Image pulled successfully", "info") // Step 2: Ensure network exists. networkID, err := d.docker.EnsureNetwork(ctx, settings.Network) if err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("ensure network: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("ensure network: %w", err) } d.logDeploy(deployID, fmt.Sprintf("Network %s ready (ID: %s)", settings.Network, truncateID(networkID)), "info") @@ -318,7 +318,7 @@ func (d *Deployer) executeDeploy( d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info") containerID, err = d.docker.CreateContainer(ctx, containerCfg) if err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("create container: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("create container: %w", err) } d.logDeploy(deployID, fmt.Sprintf("Container created (ID: %s)", truncateID(containerID)), "info") @@ -334,7 +334,7 @@ func (d *Deployer) executeDeploy( Port: project.Port, }) if err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("create instance record: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("create instance record: %w", err) } instanceID = inst.ID @@ -345,7 +345,7 @@ func (d *Deployer) executeDeploy( d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info") if err := d.docker.StartContainer(ctx, containerID); err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("start container: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("start container: %w", err) } if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil { @@ -364,13 +364,13 @@ func (d *Deployer) executeDeploy( } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - npmProxyID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) if err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("configure proxy: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err) } - // Update instance with NPM proxy ID. - inst.NpmProxyID = npmProxyID + // Update instance with proxy route ID. + inst.ProxyRouteID = proxyRouteID inst.Subdomain = subdomain if err := d.store.UpdateInstance(inst); err != nil { slog.Warn("update instance with proxy ID", "error", err) @@ -398,19 +398,19 @@ func (d *Deployer) executeDeploy( d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info") if err := d.health.Check(ctx, healthURL); err != nil { - return containerID, npmProxyID, instanceID, fmt.Errorf("health check: %w", err) + return containerID, proxyRouteID, instanceID, fmt.Errorf("health check: %w", err) } d.logDeploy(deployID, "Health check passed", "info") } else { d.logDeploy(deployID, "No health check configured, skipping", "info") } - return containerID, npmProxyID, instanceID, nil + return containerID, proxyRouteID, instanceID, nil } -// configureProxy creates or updates an NPM proxy host for the deployed container. -// It authenticates to NPM using credentials from settings, then creates the proxy. -// Returns the NPM proxy host ID. +// configureProxy creates or updates a proxy route for the deployed container. +// Uses the configured proxy.Provider (NPM, Traefik, or None). +// Returns the proxy route ID string. func (d *Deployer) configureProxy( ctx context.Context, deployID string, @@ -418,64 +418,21 @@ func (d *Deployer) configureProxy( containerName string, containerPort int, subdomain string, -) (int, error) { - // Authenticate to NPM. - npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) - if err != nil { - return 0, fmt.Errorf("decrypt npm password: %w", err) - } - - if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { - return 0, fmt.Errorf("authenticate to npm: %w", err) - } - +) (string, error) { fqdn := subdomain + "." + settings.Domain - d.logDeploy(deployID, fmt.Sprintf("Configuring proxy: %s -> %s:%d", fqdn, containerName, containerPort), "info") + d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, containerName, containerPort), "info") - // Check if a proxy host already exists for this domain. - existing, found, err := d.npm.FindProxyHostByDomain(ctx, fqdn) + routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, containerName, containerPort, proxy.RouteOptions{ + SSLCertificateID: settings.SSLCertificateID, + }) if err != nil { - return 0, fmt.Errorf("find existing proxy host: %w", err) + return "", fmt.Errorf("configure proxy route: %w", err) } - proxyConfig := npm.ProxyHostConfig{ - DomainNames: []string{fqdn}, - ForwardScheme: "http", - ForwardHost: containerName, - ForwardPort: containerPort, - BlockExploits: true, - AllowWebsocket: true, - HTTP2Support: true, - Meta: npm.Meta{}, - Locations: []any{}, + if routeID != "" { + d.logDeploy(deployID, fmt.Sprintf("Proxy route configured (ID: %s)", routeID), "info") } - - // Apply SSL certificate if configured in settings. - if settings.SSLCertificateID > 0 { - proxyConfig.CertificateID = settings.SSLCertificateID - proxyConfig.SSLForced = true - proxyConfig.HSTSEnabled = true - proxyConfig.HTTP2Support = true - d.logDeploy(deployID, fmt.Sprintf("Using SSL certificate ID %d", settings.SSLCertificateID), "info") - } - - if found { - d.logDeploy(deployID, fmt.Sprintf("Updating existing proxy host %d for %s", existing.ID, fqdn), "info") - host, err := d.npm.UpdateProxyHost(ctx, existing.ID, proxyConfig) - if err != nil { - return 0, fmt.Errorf("update proxy host: %w", err) - } - d.logDeploy(deployID, "Proxy host updated", "info") - return host.ID, nil - } - - d.logDeploy(deployID, fmt.Sprintf("Creating new proxy host for %s", fqdn), "info") - host, err := d.npm.CreateProxyHost(ctx, proxyConfig) - if err != nil { - return 0, fmt.Errorf("create proxy host: %w", err) - } - d.logDeploy(deployID, fmt.Sprintf("Proxy host created (ID: %d)", host.ID), "info") - return host.ID, nil + return routeID, nil } // enforceMaxInstances removes the oldest instances when the stage has reached its limit. @@ -538,15 +495,10 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett } } - // Delete NPM proxy host. - if inst.NpmProxyID > 0 { - npmPassword, err := d.decryptNpmPassword(settings.NpmPassword) - if err != nil { - slog.Warn("decrypt npm password for proxy cleanup", "error", err) - } else if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr != nil { - slog.Warn("authenticate npm for proxy cleanup", "error", authErr) - } else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil { - slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr) + // Delete proxy route. + if inst.ProxyRouteID != "" { + if err := d.proxy.DeleteRoute(ctx, inst.ProxyRouteID); err != nil { + slog.Warn("delete proxy route", "route_id", inst.ProxyRouteID, "error", err) } // Remove DNS record for this instance. @@ -591,15 +543,6 @@ func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) { return "", nil } -// decryptNpmPassword decrypts the NPM password from settings. -// Returns empty string if the encrypted password is empty. -func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error) { - if encryptedPassword == "" { - return "", nil - } - return crypto.Decrypt(d.encKey, encryptedPassword) -} - // mergeEnvVars builds the final environment variable list for a container: // 1. Parse project-level env JSON // 2. Overlay with stage-level env overrides (stage wins on key conflict) diff --git a/internal/deployer/rollback.go b/internal/deployer/rollback.go index 8b047c4..b678eda 100644 --- a/internal/deployer/rollback.go +++ b/internal/deployer/rollback.go @@ -7,9 +7,9 @@ import ( ) // rollback cleans up a failed deployment by removing the container, -// deleting the NPM proxy host, and updating the instance status. +// deleting the proxy route, and updating the instance status. // Errors during rollback are logged but do not prevent other cleanup steps. -func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, npmProxyID int, instanceID string) { +func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, proxyRouteID string, instanceID string) { d.logDeploy(deployID, "Rolling back failed deployment", "warn") // Remove the container if it was created. @@ -22,23 +22,13 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st } } - // Delete the NPM proxy host if it was created. - if npmProxyID > 0 { - settings, err := d.store.GetSettings() - if err != nil { - slog.Warn("rollback: get settings for npm auth", "error", err) - d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to get settings for proxy cleanup: %v", err), "error") - } else if npmPassword, err := d.decryptNpmPassword(settings.NpmPassword); err != nil { - slog.Warn("rollback: decrypt npm password", "error", err) - d.logDeploy(deployID, "Rollback: failed to decrypt NPM password for proxy cleanup", "error") - } else if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { - slog.Warn("rollback: authenticate npm", "error", err) - d.logDeploy(deployID, "Rollback: failed to authenticate NPM for proxy cleanup", "error") - } else if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil { - slog.Warn("rollback: delete proxy host", "proxy_id", npmProxyID, "error", err) - d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error") + // Delete the proxy route if it was created. + if proxyRouteID != "" { + if err := d.proxy.DeleteRoute(ctx, proxyRouteID); err != nil { + slog.Warn("rollback: delete proxy route", "route_id", proxyRouteID, "error", err) + d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy route: %v", err), "error") } else { - d.logDeploy(deployID, "Rollback: proxy host deleted", "info") + d.logDeploy(deployID, "Rollback: proxy route deleted", "info") } } diff --git a/internal/proxy/manager.go b/internal/proxy/manager.go index ac24615..e8a3eb6 100644 --- a/internal/proxy/manager.go +++ b/internal/proxy/manager.go @@ -8,23 +8,22 @@ import ( "sync" "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 - dnsMu sync.RWMutex - dns dns.Provider // nil when wildcard DNS is active + 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, npmClient *npm.Client) *Manager { +func NewManager(st *store.Store, provider Provider) *Manager { return &Manager{ - store: st, - npm: npmClient, + store: st, + provider: provider, } } @@ -70,7 +69,7 @@ type ProxyView struct { CreatedAt string `json:"created_at"` } -// CreateProxy validates the destination, creates an NPM proxy host, and saves to the store. +// 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) @@ -84,29 +83,16 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor 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) + // 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 NPM proxy host: %w", err) + return store.StandaloneProxy{}, fmt.Errorf("create proxy route: %w", err) } - slog.Info("created NPM proxy host for standalone proxy", - "domain", req.Domain, "npm_proxy_id", npmHost.ID) + 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{ @@ -114,14 +100,13 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor 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) + // 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) } @@ -132,7 +117,7 @@ func (m *Manager) CreateProxy(ctx context.Context, req CreateProxyRequest) (stor return proxy, nil } -// UpdateProxy re-validates the destination, updates the NPM proxy host, and updates the store. +// 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 { @@ -151,23 +136,11 @@ func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyReq 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 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. @@ -191,23 +164,24 @@ func (m *Manager) UpdateProxy(ctx context.Context, id string, req UpdateProxyReq return m.store.GetStandaloneProxy(id) } -// DeleteProxy removes the NPM proxy host and deletes from the store. +// DeleteProxy removes the proxy route via the provider and deletes from the store. func (m *Manager) DeleteProxy(ctx context.Context, id string) error { - proxy, err := m.store.GetStandaloneProxy(id) + p, 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) + // 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, proxy.Domain) + m.removeDNS(ctx, p.Domain) if err := m.store.DeleteStandaloneProxy(id); err != nil { return fmt.Errorf("delete standalone proxy: %w", err) @@ -257,7 +231,7 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) { }) } - // Deploy-managed proxies: instances with npm_proxy_id > 0. + // 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) @@ -278,7 +252,7 @@ func (m *Manager) ListAllProxies() ([]ProxyView, error) { } for _, inst := range instances { - if inst.NpmProxyID <= 0 { + if inst.ProxyRouteID == "" && inst.NpmProxyID <= 0 { continue } diff --git a/internal/proxy/none.go b/internal/proxy/none.go new file mode 100644 index 0000000..5fef827 --- /dev/null +++ b/internal/proxy/none.go @@ -0,0 +1,26 @@ +package proxy + +import "context" + +// NoneProvider is a no-op proxy provider for deployments that don't use a reverse proxy. +type NoneProvider struct{} + +func NewNoneProvider() *NoneProvider { return &NoneProvider{} } + +func (n *NoneProvider) Name() string { return "none" } + +func (n *NoneProvider) ConfigureRoute(_ context.Context, _, _ string, _ int, _ RouteOptions) (string, error) { + return "", nil +} + +func (n *NoneProvider) DeleteRoute(_ context.Context, _ string) error { + return nil +} + +func (n *NoneProvider) ContainerLabels(_ string, _ int) map[string]string { + return nil +} + +func (n *NoneProvider) Ping(_ context.Context) error { + return nil +} diff --git a/internal/proxy/npm_provider.go b/internal/proxy/npm_provider.go new file mode 100644 index 0000000..d494103 --- /dev/null +++ b/internal/proxy/npm_provider.go @@ -0,0 +1,118 @@ +package proxy + +import ( + "context" + "fmt" + "strconv" + + "github.com/alexei/docker-watcher/internal/npm" +) + +// NpmProvider wraps the NPM client behind the Provider interface. +// It handles authentication transparently before each operation. +type NpmProvider struct { + client *npm.Client + email string + password string +} + +// NewNpmProvider creates an NPM-backed proxy provider. +// The email and password are the decrypted NPM credentials. +func NewNpmProvider(client *npm.Client, email, password string) *NpmProvider { + return &NpmProvider{ + client: client, + email: email, + password: password, + } +} + +// UpdateCredentials updates the stored NPM credentials (e.g., after settings change). +func (p *NpmProvider) UpdateCredentials(email, password string) { + p.email = email + p.password = password +} + +func (p *NpmProvider) Name() string { return "npm" } + +func (p *NpmProvider) ConfigureRoute(ctx context.Context, domain, targetHost string, targetPort int, opts RouteOptions) (string, error) { + if err := p.auth(ctx); err != nil { + return "", err + } + + scheme := opts.ForwardScheme + if scheme == "" { + scheme = "http" + } + + // Check if a proxy host already exists for this domain. + existing, found, err := p.client.FindProxyHostByDomain(ctx, domain) + if err != nil { + return "", fmt.Errorf("find existing proxy host: %w", err) + } + + config := npm.ProxyHostConfig{ + DomainNames: []string{domain}, + ForwardScheme: scheme, + ForwardHost: targetHost, + ForwardPort: targetPort, + BlockExploits: true, + AllowWebsocket: true, + HTTP2Support: true, + Meta: npm.Meta{}, + Locations: []any{}, + } + + if opts.SSLCertificateID > 0 { + config.CertificateID = opts.SSLCertificateID + config.SSLForced = true + config.HSTSEnabled = true + } + + if found { + host, err := p.client.UpdateProxyHost(ctx, existing.ID, config) + if err != nil { + return "", fmt.Errorf("update proxy host: %w", err) + } + return strconv.Itoa(host.ID), nil + } + + host, err := p.client.CreateProxyHost(ctx, config) + if err != nil { + return "", fmt.Errorf("create proxy host: %w", err) + } + return strconv.Itoa(host.ID), nil +} + +func (p *NpmProvider) DeleteRoute(ctx context.Context, routeID string) error { + if routeID == "" { + return nil + } + + id, err := strconv.Atoi(routeID) + if err != nil { + return fmt.Errorf("invalid npm proxy host id %q: %w", routeID, err) + } + + if err := p.auth(ctx); err != nil { + return err + } + + return p.client.DeleteProxyHost(ctx, id) +} + +func (p *NpmProvider) ContainerLabels(_ string, _ int) map[string]string { + // NPM configures routing via its API, not Docker labels. + return nil +} + +func (p *NpmProvider) Ping(ctx context.Context) error { + return p.client.Ping(ctx) +} + +// auth authenticates to NPM if credentials are available. +func (p *NpmProvider) auth(ctx context.Context) error { + if p.email == "" { + return fmt.Errorf("NPM credentials not configured") + } + return p.client.Authenticate(ctx, p.email, p.password) +} diff --git a/internal/proxy/provider.go b/internal/proxy/provider.go new file mode 100644 index 0000000..1c5a50e --- /dev/null +++ b/internal/proxy/provider.go @@ -0,0 +1,36 @@ +package proxy + +import "context" + +// RouteOptions holds optional configuration for a proxy route. +type RouteOptions struct { + SSLCertificateID int + ForwardScheme string // "http" or "https", defaults to "http" +} + +// Provider is the interface for proxy route management. +// Implementations handle the specifics of each proxy system (NPM, Traefik, etc.). +// The "None" provider implements all methods as no-ops. +type Provider interface { + // Name returns the provider identifier (e.g., "npm", "traefik", "none"). + Name() string + + // ConfigureRoute creates or updates a proxy route for the given domain. + // Returns a route ID string that can be used for updates and deletes. + // For NPM, the route ID is the proxy host ID (stringified). + // For Traefik, the route ID is the router name. + // For None, returns empty string. + ConfigureRoute(ctx context.Context, domain, targetHost string, targetPort int, opts RouteOptions) (routeID string, err error) + + // DeleteRoute removes a proxy route by its route ID. + // Does nothing if routeID is empty. + DeleteRoute(ctx context.Context, routeID string) error + + // ContainerLabels returns Docker labels to set on containers at creation time. + // Traefik uses labels for auto-discovery; NPM and None return nil. + ContainerLabels(domain string, port int) map[string]string + + // Ping checks connectivity to the proxy backend. + // Returns nil if healthy or if the provider doesn't need external connectivity (None). + Ping(ctx context.Context) error +} diff --git a/internal/store/instances.go b/internal/store/instances.go index d0fb730..ebaf84b 100644 --- a/internal/store/instances.go +++ b/internal/store/instances.go @@ -9,14 +9,14 @@ import ( ) // instanceColumns is the canonical column list for instance queries. -const instanceColumns = `id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, status, port, last_alive_at, created_at, updated_at` +const instanceColumns = `id, stage_id, project_id, container_id, image_tag, subdomain, npm_proxy_id, proxy_route_id, status, port, last_alive_at, created_at, updated_at` // scanInstance scans a row into an Instance struct using the canonical column order. func scanInstance(scanner interface{ Scan(...any) error }) (Instance, error) { var inst Instance err := scanner.Scan( &inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag, - &inst.Subdomain, &inst.NpmProxyID, &inst.Status, &inst.Port, + &inst.Subdomain, &inst.NpmProxyID, &inst.ProxyRouteID, &inst.Status, &inst.Port, &inst.LastAliveAt, &inst.CreatedAt, &inst.UpdatedAt, ) return inst, err @@ -30,9 +30,9 @@ func (s *Store) CreateInstance(inst Instance) (Instance, error) { _, err := s.db.Exec( `INSERT INTO instances (`+instanceColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag, - inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, + inst.Subdomain, inst.NpmProxyID, inst.ProxyRouteID, inst.Status, inst.Port, inst.LastAliveAt, inst.CreatedAt, inst.UpdatedAt, ) if err != nil { @@ -52,9 +52,9 @@ func (s *Store) CreateInstanceWithID(inst Instance) (Instance, error) { _, err := s.db.Exec( `INSERT INTO instances (`+instanceColumns+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, inst.ID, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag, - inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, + inst.Subdomain, inst.NpmProxyID, inst.ProxyRouteID, inst.Status, inst.Port, inst.LastAliveAt, inst.CreatedAt, inst.UpdatedAt, ) if err != nil { @@ -123,10 +123,10 @@ func (s *Store) ListAllInstances() ([]Instance, error) { func (s *Store) UpdateInstance(inst Instance) error { inst.UpdatedAt = Now() result, err := s.db.Exec( - `UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, status=?, port=?, last_alive_at=?, updated_at=? + `UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, proxy_route_id=?, status=?, port=?, last_alive_at=?, updated_at=? WHERE id=?`, inst.StageID, inst.ProjectID, inst.ContainerID, inst.ImageTag, - inst.Subdomain, inst.NpmProxyID, inst.Status, inst.Port, + inst.Subdomain, inst.NpmProxyID, inst.ProxyRouteID, inst.Status, inst.Port, inst.LastAliveAt, inst.UpdatedAt, inst.ID, ) if err != nil { diff --git a/internal/store/models.go b/internal/store/models.go index e06e638..c81f3dd 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -63,6 +63,7 @@ type Settings struct { DNSProvider string `json:"dns_provider"` CloudflareAPIToken string `json:"cloudflare_api_token"` CloudflareZoneID string `json:"cloudflare_zone_id"` + ProxyProvider string `json:"proxy_provider"` BackupEnabled bool `json:"backup_enabled"` BackupIntervalHours int `json:"backup_interval_hours"` BackupRetentionCount int `json:"backup_retention_count"` @@ -97,9 +98,10 @@ type Instance struct { ContainerID string `json:"container_id"` ImageTag string `json:"image_tag"` Subdomain string `json:"subdomain"` - NpmProxyID int `json:"npm_proxy_id"` - Status string `json:"status"` // running, stopped, failed, removing - Port int `json:"port"` + NpmProxyID int `json:"npm_proxy_id"` + ProxyRouteID string `json:"proxy_route_id"` + Status string `json:"status"` // running, stopped, failed, removing + Port int `json:"port"` LastAliveAt string `json:"last_alive_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` diff --git a/internal/store/settings.go b/internal/store/settings.go index 9da3135..e8ae580 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -14,6 +14,7 @@ func (s *Store) GetSettings() (Settings, error) { base_volume_path, ssl_certificate_id, stale_threshold_days, allowed_volume_paths, wildcard_dns, dns_provider, cloudflare_api_token, cloudflare_zone_id, + proxy_provider, backup_enabled, backup_interval_hours, backup_retention_count, updated_at FROM settings WHERE id = 1`, @@ -22,6 +23,7 @@ func (s *Store) GetSettings() (Settings, error) { &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider, &st.CloudflareAPIToken, &st.CloudflareZoneID, + &st.ProxyProvider, &backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount, &st.UpdatedAt) if err != nil { @@ -50,6 +52,7 @@ func (s *Store) UpdateSettings(st Settings) error { base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, allowed_volume_paths=?, wildcard_dns=?, dns_provider=?, cloudflare_api_token=?, cloudflare_zone_id=?, + proxy_provider=?, backup_enabled=?, backup_interval_hours=?, backup_retention_count=?, updated_at=? WHERE id = 1`, @@ -58,6 +61,7 @@ func (s *Store) UpdateSettings(st Settings) error { st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.AllowedVolumePaths, wildcardDNS, st.DNSProvider, st.CloudflareAPIToken, st.CloudflareZoneID, + st.ProxyProvider, backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount, st.UpdatedAt, ) diff --git a/internal/store/store.go b/internal/store/store.go index c8d03b7..a466fac 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -100,6 +100,10 @@ func (s *Store) runMigrations() error { `ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`, `ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`, `ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`, + // Add proxy_route_id to instances for provider-agnostic route tracking (2026-04-04). + `ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`, + // Add proxy_provider to settings (2026-04-04). Default to npm for backward compat. + `ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`, } for _, m := range migrations {