From 195ef3e7e5d9f9e4f1cd7ec8e1f040188775dbbf Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 5 Apr 2026 02:18:06 +0300 Subject: [PATCH] feat: NPM remote mode for cross-machine deployments - Add npm_remote setting: when enabled, proxy forwards to server_ip with published host ports instead of Docker container names - Deployer looks up assigned host port via InspectContainerPort in remote mode - Auto-remove stale containers with same name before creating new ones - Add Remote NPM toggle with warning on NPM settings page - DB migration + schema for npm_remote column --- internal/api/settings.go | 5 ++++ internal/deployer/bluegreen.go | 2 +- internal/deployer/deployer.go | 31 +++++++++++++++++++++--- internal/store/models.go | 1 + internal/store/settings.go | 15 ++++++++---- internal/store/store.go | 3 +++ web/src/lib/i18n/en.json | 5 +++- web/src/lib/i18n/ru.json | 5 +++- web/src/lib/types.ts | 1 + web/src/routes/settings/npm/+page.svelte | 21 +++++++++++++++- 10 files changed, 77 insertions(+), 12 deletions(-) diff --git a/internal/api/settings.go b/internal/api/settings.go index a2fdc1a..7c28f5a 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -36,6 +36,7 @@ type settingsRequest struct { DNSProvider *string `json:"dns_provider,omitempty"` CloudflareAPIToken string `json:"cloudflare_api_token"` CloudflareZoneID *string `json:"cloudflare_zone_id,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"` @@ -64,6 +65,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "npm_url": settings.NpmURL, "npm_email": settings.NpmEmail, "has_npm_password": settings.NpmPassword != "", + "npm_remote": settings.NpmRemote, "polling_interval": settings.PollingInterval, "ssl_certificate_id": settings.SSLCertificateID, "stale_threshold_days": settings.StaleThresholdDays, @@ -187,6 +189,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { } updated.ProxyProvider = prov } + if req.NpmRemote != nil { + updated.NpmRemote = *req.NpmRemote + } // Traefik provider settings. if req.TraefikEntrypoint != nil { diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index 4793405..7e3fdbd 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -162,7 +162,7 @@ func (d *Deployer) blueGreenDeploy( } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain) if err != nil { return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err) } diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 5518abe..2510f66 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -306,6 +306,10 @@ func (d *Deployer) executeDeploy( subdomain := d.buildSubdomain(project, stage, settings, imageTag) containerName := docker.ContainerName(project.Name, stage.Name, imageTag) + + // Remove any stale container with the same name (e.g., from a previous failed deploy). + _ = d.docker.RemoveContainer(ctx, containerName, true) + portStr := fmt.Sprintf("%d/tcp", project.Port) envVars := d.mergeEnvVars(project, stage.ID) mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath) @@ -385,7 +389,7 @@ func (d *Deployer) executeDeploy( } d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "") - proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain) + proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain) if err != nil { return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err) } @@ -431,19 +435,40 @@ func (d *Deployer) executeDeploy( // configureProxy creates or updates a proxy route for the deployed container. // Uses the configured proxy.Provider (NPM, Traefik, or None). +// In NPM remote mode, uses server_ip + published host port instead of container name. // Returns the proxy route ID string. func (d *Deployer) configureProxy( ctx context.Context, deployID string, settings store.Settings, + containerID string, containerName string, containerPort int, subdomain string, ) (string, error) { fqdn := subdomain + "." + settings.Domain - d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, containerName, containerPort), "info") - routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, containerName, containerPort, proxy.RouteOptions{ + forwardHost := containerName + forwardPort := containerPort + + // In NPM remote mode, use server_ip and the published host port. + if settings.NpmRemote && settings.ProxyProvider == "npm" { + if settings.ServerIP == "" { + return "", fmt.Errorf("NPM remote mode requires Server IP to be configured in settings") + } + forwardHost = settings.ServerIP + + hostPort, err := d.docker.InspectContainerPort(ctx, containerID, fmt.Sprintf("%d/tcp", containerPort)) + if err != nil { + return "", fmt.Errorf("look up host port for remote NPM: %w", err) + } + forwardPort = int(hostPort) + d.logDeploy(deployID, fmt.Sprintf("NPM remote mode: using %s:%d (host port)", forwardHost, forwardPort), "info") + } + + d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, forwardHost, forwardPort), "info") + + routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{ SSLCertificateID: settings.SSLCertificateID, }) if err != nil { diff --git a/internal/store/models.go b/internal/store/models.go index ed67538..ce30427 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"` + NpmRemote bool `json:"npm_remote"` ProxyProvider string `json:"proxy_provider"` TraefikEntrypoint string `json:"traefik_entrypoint"` TraefikCertResolver string `json:"traefik_cert_resolver"` diff --git a/internal/store/settings.go b/internal/store/settings.go index 6f29f5d..b09fccb 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -7,14 +7,14 @@ import ( // GetSettings returns the global settings (single-row pattern, always row id=1). func (s *Store) GetSettings() (Settings, error) { var st Settings - var wildcardDNS, backupEnabled int + var wildcardDNS, npmRemote, backupEnabled int err := s.db.QueryRow( `SELECT domain, server_ip, network, subdomain_pattern, notification_url, npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, allowed_volume_paths, wildcard_dns, dns_provider, cloudflare_api_token, cloudflare_zone_id, - proxy_provider, + npm_remote, proxy_provider, traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url, backup_enabled, backup_interval_hours, backup_retention_count, updated_at @@ -24,7 +24,7 @@ func (s *Store) GetSettings() (Settings, error) { &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider, &st.CloudflareAPIToken, &st.CloudflareZoneID, - &st.ProxyProvider, + &npmRemote, &st.ProxyProvider, &st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL, &backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount, &st.UpdatedAt) @@ -32,6 +32,7 @@ func (s *Store) GetSettings() (Settings, error) { return Settings{}, fmt.Errorf("query settings: %w", err) } st.WildcardDNS = wildcardDNS != 0 + st.NpmRemote = npmRemote != 0 st.BackupEnabled = backupEnabled != 0 return st, nil } @@ -43,6 +44,10 @@ func (s *Store) UpdateSettings(st Settings) error { if st.WildcardDNS { wildcardDNS = 1 } + npmRemote := 0 + if st.NpmRemote { + npmRemote = 1 + } backupEnabled := 0 if st.BackupEnabled { backupEnabled = 1 @@ -54,7 +59,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=?, + npm_remote=?, proxy_provider=?, traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?, backup_enabled=?, backup_interval_hours=?, backup_retention_count=?, updated_at=? @@ -64,7 +69,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, + npmRemote, st.ProxyProvider, st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL, backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount, st.UpdatedAt, diff --git a/internal/store/store.go b/internal/store/store.go index 1482685..dbc624b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -111,6 +111,8 @@ func (s *Store) runMigrations() error { `ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`, // Set default network for existing databases with empty network. `UPDATE settings SET network = 'docker-watcher' WHERE network = ''`, + // NPM remote mode: forward to server_ip instead of container name. + `ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`, } for _, m := range migrations { @@ -210,6 +212,7 @@ CREATE TABLE IF NOT EXISTS settings ( polling_interval TEXT NOT NULL DEFAULT '5m', base_volume_path TEXT NOT NULL DEFAULT '', ssl_certificate_id INTEGER NOT NULL DEFAULT 0, + npm_remote INTEGER NOT NULL DEFAULT 0, traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure', traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt', traefik_network TEXT NOT NULL DEFAULT '', diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index e2a52a7..9add980 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -373,7 +373,10 @@ "testing": "Testing...", "testSuccess": "NPM connection successful", "testFailed": "NPM connection failed", - "saveFailedConnection": "Cannot save \u2014 connection test failed" + "saveFailedConnection": "Cannot save \u2014 connection test failed", + "remoteMode": "Remote NPM", + "remoteModeHelp": "Enable when NPM runs on a different machine than Docker. Forwards to Server IP with published host ports.", + "remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports." }, "settingsCredentials": { "title": "Credentials", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 553318b..19e2455 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -373,7 +373,10 @@ "testing": "Проверка...", "testSuccess": "Подключение к NPM успешно", "testFailed": "Не удалось подключиться к NPM", - "saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена" + "saveFailedConnection": "Невозможно сохранить — проверка соединения не пройдена", + "remoteMode": "Удалённый NPM", + "remoteModeHelp": "Включите, если NPM работает на другой машине. Перенаправление на IP сервера с опубликованными портами.", + "remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста." }, "settingsCredentials": { "title": "Учётные данные", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 13fefc7..528392e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -106,6 +106,7 @@ export interface Settings { has_npm_password: boolean; /** Sent on PUT to update the password; never returned by GET. */ npm_password?: string; + npm_remote: boolean; polling_interval: string; base_volume_path: string; ssl_certificate_id: number; diff --git a/web/src/routes/settings/npm/+page.svelte b/web/src/routes/settings/npm/+page.svelte index 79113d0..14dc7b1 100644 --- a/web/src/routes/settings/npm/+page.svelte +++ b/web/src/routes/settings/npm/+page.svelte @@ -2,6 +2,7 @@ import { getSettings, updateSettings, listNpmCertificates, testNpmConnection } from '$lib/api'; import type { EntityPickerItem } from '$lib/types'; import FormField from '$lib/components/FormField.svelte'; + import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte'; import Skeleton from '$lib/components/Skeleton.svelte'; import { toasts } from '$lib/stores/toast'; @@ -15,6 +16,7 @@ let npmEmail = $state(''); let npmPassword = $state(''); let npmHasCredentials = $state(false); + let npmRemote = $state(false); let editingNpm = $state(false); let errors = $state>({}); @@ -42,6 +44,7 @@ npmHasCredentials = !!(settings.npm_url && settings.npm_email); npmPassword = ''; sslCertificateId = settings.ssl_certificate_id ?? 0; + npmRemote = settings.npm_remote ?? false; if (sslCertificateId > 0) sslCertName = `Certificate #${sslCertificateId}`; } catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; } } @@ -77,7 +80,7 @@ return; } try { - const payload: Record = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() }; + const payload: Record = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim(), npm_remote: npmRemote }; if (npmPassword.trim()) payload.npm_password = npmPassword.trim(); await updateSettings(payload); npmHasCredentials = true; @@ -195,6 +198,22 @@ {/if} + +
+
+ +
+ {$t('settingsNpm.remoteMode')} +

{$t('settingsNpm.remoteModeHelp')}

+
+
+ {#if npmRemote} +
+

{$t('settingsNpm.remoteModeWarning')}

+
+ {/if} +
+