From 9f284932a12ebfee1e9aa20e7683e768c5b3db12 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 29 Mar 2026 13:07:58 +0300 Subject: [PATCH] feat: SSL wildcard certificate picker from NPM - NPM client: ListCertificates endpoint - API: GET /api/settings/npm-certificates (wildcard-only filter) - Settings UI: EntityPicker for selecting wildcard certs - Deployer: applies certificate_id + ssl_forced to proxy hosts - Uses HTTPS subdomain URLs when SSL cert is configured --- internal/api/router.go | 1 + internal/api/settings.go | 84 ++++++++++++++++--- internal/deployer/deployer.go | 9 +++ internal/npm/client.go | 9 +++ internal/npm/types.go | 9 +++ internal/store/models.go | 1 + internal/store/settings.go | 8 +- internal/store/store.go | 3 + web/src/lib/api.ts | 5 ++ web/src/lib/i18n/en.json | 9 ++- web/src/lib/i18n/ru.json | 9 ++- web/src/lib/types.ts | 10 +++ web/src/routes/settings/+page.svelte | 117 +++++++++++++++++++++++++-- 13 files changed, 253 insertions(+), 21 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index f88fa25..dfb0221 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -131,6 +131,7 @@ func (s *Server) Router() chi.Router { r.Get("/images", s.listRegistryImages) }) r.Get("/settings", s.getSettings) + r.Get("/settings/npm-certificates", s.listNpmCertificates) // Admin-only routes: require admin role. r.Group(func(r chi.Router) { diff --git a/internal/api/settings.go b/internal/api/settings.go index bbcf69a..2dde35b 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -3,8 +3,10 @@ package api import ( "fmt" "net/http" + "strings" "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/webhook" ) @@ -19,6 +21,7 @@ type settingsRequest struct { NpmEmail string `json:"npm_email"` NpmPassword string `json:"npm_password"` PollingInterval string `json:"polling_interval"` + SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` } // getSettings handles GET /api/settings. @@ -31,16 +34,17 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { // Return settings without sensitive fields. respondJSON(w, http.StatusOK, map[string]any{ - "domain": settings.Domain, - "server_ip": settings.ServerIP, - "network": settings.Network, - "subdomain_pattern": settings.SubdomainPattern, - "notification_url": settings.NotificationURL, - "npm_url": settings.NpmURL, - "npm_email": settings.NpmEmail, - "has_npm_password": settings.NpmPassword != "", - "polling_interval": settings.PollingInterval, - "updated_at": settings.UpdatedAt, + "domain": settings.Domain, + "server_ip": settings.ServerIP, + "network": settings.Network, + "subdomain_pattern": settings.SubdomainPattern, + "notification_url": settings.NotificationURL, + "npm_url": settings.NpmURL, + "npm_email": settings.NpmEmail, + "has_npm_password": settings.NpmPassword != "", + "polling_interval": settings.PollingInterval, + "ssl_certificate_id": settings.SSLCertificateID, + "updated_at": settings.UpdatedAt, }) } @@ -89,6 +93,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { if req.PollingInterval != "" { updated.PollingInterval = req.PollingInterval } + if req.SSLCertificateID != nil { + updated.SSLCertificateID = *req.SSLCertificateID + } if err := s.store.UpdateSettings(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) @@ -140,3 +147,60 @@ func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) }) } +// listNpmCertificates handles GET /api/settings/npm-certificates. +// It authenticates to NPM using the stored credentials and returns only wildcard certificates. +func (s *Server) listNpmCertificates(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.GetSettings() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error()) + return + } + + if settings.NpmURL == "" || settings.NpmEmail == "" || settings.NpmPassword == "" { + respondError(w, http.StatusBadRequest, "NPM credentials not configured") + return + } + + npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt npm password: "+err.Error()) + return + } + + client := npm.New(settings.NpmURL) + if err := client.Authenticate(r.Context(), settings.NpmEmail, npmPassword); err != nil { + respondError(w, http.StatusBadGateway, "failed to authenticate to NPM: "+err.Error()) + return + } + + certs, err := client.ListCertificates(r.Context()) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to list certificates: "+err.Error()) + return + } + + // Filter to wildcard certificates only. + var wildcards []npm.Certificate + for _, cert := range certs { + if isWildcardCert(cert) { + wildcards = append(wildcards, cert) + } + } + + if wildcards == nil { + wildcards = []npm.Certificate{} + } + + respondJSON(w, http.StatusOK, wildcards) +} + +// isWildcardCert returns true if any of the certificate's domain names contains "*". +func isWildcardCert(cert npm.Certificate) bool { + for _, d := range cert.DomainNames { + if strings.Contains(d, "*") { + return true + } + } + return false +} + diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 99e12e4..f8442f9 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -425,6 +425,15 @@ func (d *Deployer) configureProxy( Locations: []any{}, } + // 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) diff --git a/internal/npm/client.go b/internal/npm/client.go index e07eb7d..a95a65e 100644 --- a/internal/npm/client.go +++ b/internal/npm/client.go @@ -154,6 +154,15 @@ func (c *Client) FindProxyHostByDomain(ctx context.Context, domain string) (Prox return ProxyHost{}, false, nil } +// ListCertificates returns all SSL certificates from NPM. +func (c *Client) ListCertificates(ctx context.Context) ([]Certificate, error) { + var certs []Certificate + if err := c.doJSON(ctx, http.MethodGet, "/nginx/certificates", nil, &certs); err != nil { + return nil, fmt.Errorf("list certificates: %w", err) + } + return certs, nil +} + // doJSON performs an authenticated JSON API request. If the token is expired or a 401 // is received, it automatically re-authenticates and retries the request once. func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error { diff --git a/internal/npm/types.go b/internal/npm/types.go index 5da2744..aa584e1 100644 --- a/internal/npm/types.go +++ b/internal/npm/types.go @@ -50,6 +50,15 @@ type ProxyHost struct { ModifiedOn string `json:"modified_on"` } +// Certificate represents an SSL certificate as returned by the NPM API. +type Certificate struct { + ID int `json:"id"` + NiceName string `json:"nice_name"` + DomainNames []string `json:"domain_names"` + ExpiresOn string `json:"expires_on"` + Provider string `json:"provider"` +} + // boolInt handles the NPM API's inconsistent use of 0/1 integers for boolean fields. type boolInt bool diff --git a/internal/store/models.go b/internal/store/models.go index 304ba41..72e823f 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -55,6 +55,7 @@ type Settings struct { WebhookSecret string `json:"webhook_secret"` PollingInterval string `json:"polling_interval"` BaseVolumePath string `json:"base_volume_path"` + SSLCertificateID int `json:"ssl_certificate_id"` UpdatedAt string `json:"updated_at"` } diff --git a/internal/store/settings.go b/internal/store/settings.go index 05319e3..1580cd8 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -9,10 +9,10 @@ func (s *Store) GetSettings() (Settings, error) { var st Settings 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, updated_at + npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, updated_at FROM settings WHERE id = 1`, ).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, - &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.UpdatedAt) + &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.UpdatedAt) if err != nil { return Settings{}, fmt.Errorf("query settings: %w", err) } @@ -25,10 +25,10 @@ func (s *Store) UpdateSettings(st Settings) error { _, err := s.db.Exec( `UPDATE settings SET domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, - npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, updated_at=? + npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, updated_at=? WHERE id = 1`, st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL, - st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.UpdatedAt, + st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.UpdatedAt, ) if err != nil { return fmt.Errorf("update settings: %w", err) diff --git a/internal/store/store.go b/internal/store/store.go index 38041c9..9dbda01 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -79,6 +79,8 @@ func (s *Store) runMigrations() error { `ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`, // Add enable_proxy to stages (2026-03-29). Default true for backwards compat. `ALTER TABLE stages ADD COLUMN enable_proxy INTEGER NOT NULL DEFAULT 1`, + // Add ssl_certificate_id to settings (2026-03-29). + `ALTER TABLE settings ADD COLUMN ssl_certificate_id INTEGER NOT NULL DEFAULT 0`, } for _, m := range migrations { @@ -159,6 +161,7 @@ CREATE TABLE IF NOT EXISTS settings ( webhook_secret TEXT NOT NULL DEFAULT '', polling_interval TEXT NOT NULL DEFAULT '5m', base_volume_path TEXT NOT NULL DEFAULT '', + ssl_certificate_id INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9f912e4..9bbc5b1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -4,6 +4,7 @@ import type { DeployLog, InspectResult, Instance, + NpmCertificate, Project, ProjectDetail, Registry, @@ -258,6 +259,10 @@ export function regenerateWebhookUrl(): Promise<{ url: string }> { return post<{ url: string }>('/api/settings/webhook-url/regenerate'); } +export function listNpmCertificates(): Promise { + return get('/api/settings/npm-certificates'); +} + // ── Auth ───────────────────────────────────────────────────────────── export function login(username: string, password: string): Promise<{ token: string; expires_at: string }> { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index fdf49ed..424d07d 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -207,7 +207,14 @@ "regenerating": "Regenerating...", "regenerated": "Webhook URL regenerated", "regenerateFailed": "Failed to regenerate webhook URL", - "regenerateWarning": "Warning: regenerating will invalidate the current URL. Update your CI pipelines." + "regenerateWarning": "Warning: regenerating will invalidate the current URL. Update your CI pipelines.", + "sslCertificate": "SSL Certificate", + "sslCertificateHelp": "Wildcard certificate from NPM for auto-SSL on proxy hosts", + "selectCertificate": "Select Certificate", + "noCertificate": "None (no SSL)", + "clearCertificate": "Clear", + "loadingCertificates": "Loading certificates...", + "noCertificatesFound": "No wildcard certificates found in NPM" }, "settingsRegistries": { "title": "Container Registries", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index fe1a4c6..082dbf7 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -207,7 +207,14 @@ "regenerating": "Перегенерация...", "regenerated": "URL вебхука перегенерирован", "regenerateFailed": "Не удалось перегенерировать URL вебхука", - "regenerateWarning": "Внимание: перегенерация сделает текущий URL недействительным. Обновите ваши CI-пайплайны." + "regenerateWarning": "Внимание: перегенерация сделает текущий URL недействительным. Обновите ваши CI-пайплайны.", + "sslCertificate": "SSL-сертификат", + "sslCertificateHelp": "Wildcard-сертификат из NPM для автоматического SSL на прокси-хостах", + "selectCertificate": "Выбрать сертификат", + "noCertificate": "Нет (без SSL)", + "clearCertificate": "Очистить", + "loadingCertificates": "Загрузка сертификатов...", + "noCertificatesFound": "Wildcard-сертификаты в NPM не найдены" }, "settingsRegistries": { "title": "Реестры контейнеров", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 245e788..c7dedc8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -105,9 +105,19 @@ export interface Settings { webhook_secret: string; polling_interval: string; base_volume_path: string; + ssl_certificate_id: number; updated_at: string; } +/** An SSL certificate from Nginx Proxy Manager. */ +export interface NpmCertificate { + id: number; + nice_name: string; + domain_names: string[]; + expires_on: string; + provider: string; +} + /** Standard API envelope returned by all backend endpoints. */ export interface ApiEnvelope { success: boolean; diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 7ca56b8..9cff48f 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -1,10 +1,11 @@ @@ -144,6 +206,42 @@ + +
+
+
+ +

{$t('settingsGeneral.sslCertificateHelp')}

+
+ + {#if sslCertificateId > 0} + + {/if} +
+
+
+
+
{/if} + + { certPickerOpen = false; }} +/>