Files
tiny-forge/internal/api/settings.go
T
alexei.dolgolyov 7d6719da12 refactor: extract ProxyProvider interface with None and NPM implementations
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.
2026-04-04 19:39:08 +03:00

582 lines
18 KiB
Go

package api
import (
"context"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"strings"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/volume"
"github.com/alexei/docker-watcher/internal/webhook"
)
// settingsRequest is the expected JSON body for updating settings.
type settingsRequest struct {
Domain string `json:"domain"`
ServerIP string `json:"server_ip"`
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"`
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"`
WildcardDNS *bool `json:"wildcard_dns,omitempty"`
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"`
}
// getSettings handles GET /api/settings.
func (s *Server) getSettings(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
}
// 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,
"ssl_certificate_id": settings.SSLCertificateID,
"stale_threshold_days": settings.StaleThresholdDays,
"allowed_volume_paths": settings.AllowedVolumePaths,
"wildcard_dns": settings.WildcardDNS,
"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,
"updated_at": settings.UpdatedAt,
})
}
// updateSettings handles PUT /api/settings.
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
var req settingsRequest
if !decodeJSON(w, r, &req) {
return
}
existing, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
updated := existing
if req.Domain != "" {
updated.Domain = req.Domain
}
if req.ServerIP != "" {
updated.ServerIP = req.ServerIP
}
if req.Network != "" {
updated.Network = req.Network
}
if req.SubdomainPattern != "" {
updated.SubdomainPattern = req.SubdomainPattern
}
// Allow clearing notification URL.
updated.NotificationURL = req.NotificationURL
if req.NpmURL != "" {
updated.NpmURL = req.NpmURL
}
if req.NpmEmail != "" {
updated.NpmEmail = req.NpmEmail
}
if req.NpmPassword != "" {
encPassword, err := crypto.Encrypt(s.encKey, req.NpmPassword)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt npm password: "+err.Error())
return
}
updated.NpmPassword = encPassword
}
if req.PollingInterval != "" {
updated.PollingInterval = req.PollingInterval
}
sslChanged := false
if req.SSLCertificateID != nil && *req.SSLCertificateID != updated.SSLCertificateID {
updated.SSLCertificateID = *req.SSLCertificateID
sslChanged = true
}
if req.StaleThresholdDays != nil {
if *req.StaleThresholdDays < 1 {
respondError(w, http.StatusBadRequest, "stale_threshold_days must be at least 1")
return
}
updated.StaleThresholdDays = *req.StaleThresholdDays
}
if req.AllowedVolumePaths != nil {
// Validate it's valid JSON array of strings.
paths, err := volume.ParseAllowedPaths(*req.AllowedVolumePaths)
if err != nil {
respondError(w, http.StatusBadRequest, "allowed_volume_paths must be a JSON array of strings")
return
}
// Validate each path is absolute.
for _, p := range paths {
if !filepath.IsAbs(p) {
respondError(w, http.StatusBadRequest, "each allowed volume path must be absolute")
return
}
}
updated.AllowedVolumePaths = *req.AllowedVolumePaths
_ = paths // validated
}
// DNS settings.
if req.WildcardDNS != nil {
updated.WildcardDNS = *req.WildcardDNS
}
if req.DNSProvider != nil {
updated.DNSProvider = *req.DNSProvider
}
if req.CloudflareAPIToken != "" {
encToken, err := crypto.Encrypt(s.encKey, req.CloudflareAPIToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to encrypt cloudflare api token: "+err.Error())
return
}
updated.CloudflareAPIToken = encToken
}
if req.CloudflareZoneID != nil {
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
}
if req.BackupIntervalHours != nil {
if *req.BackupIntervalHours < 1 {
respondError(w, http.StatusBadRequest, "backup_interval_hours must be at least 1")
return
}
updated.BackupIntervalHours = *req.BackupIntervalHours
}
if req.BackupRetentionCount != nil {
if *req.BackupRetentionCount < 1 {
respondError(w, http.StatusBadRequest, "backup_retention_count must be at least 1")
return
}
updated.BackupRetentionCount = *req.BackupRetentionCount
}
if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
return
}
// If SSL cert changed, update all existing NPM proxy hosts in the background.
if sslChanged {
go s.reapplySSLToAllProxies(updated)
}
// Handle DNS provider changes.
dnsChanged := existing.WildcardDNS != updated.WildcardDNS ||
existing.DNSProvider != updated.DNSProvider ||
existing.CloudflareZoneID != updated.CloudflareZoneID ||
(req.CloudflareAPIToken != "" && req.CloudflareAPIToken != "unchanged")
if dnsChanged {
oldProvider := s.getDNSProviderLocked()
go s.handleDNSSettingsChange(oldProvider, existing, updated)
}
// Handle backup settings changes.
backupChanged := existing.BackupEnabled != updated.BackupEnabled ||
existing.BackupIntervalHours != updated.BackupIntervalHours
if backupChanged && s.onBackupSettingsChanged != nil {
s.onBackupSettingsChanged(updated.BackupEnabled, updated.BackupIntervalHours)
}
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// getWebhookURL handles GET /api/settings/webhook-url.
func (s *Server) getWebhookURL(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
}
webhookURL := ""
if settings.WebhookSecret != "" && settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, settings.WebhookSecret)
}
respondJSON(w, http.StatusOK, map[string]string{
"webhook_url": webhookURL,
})
}
// regenerateWebhookSecret handles POST /api/settings/regenerate.
func (s *Server) regenerateWebhookSecret(w http.ResponseWriter, r *http.Request) {
secret, err := webhook.RegenerateWebhookSecret(s.store)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to regenerate webhook secret: "+err.Error())
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
webhookURL := ""
if settings.Domain != "" {
webhookURL = fmt.Sprintf("https://%s/api/webhook/%s", settings.Domain, secret)
}
respondJSON(w, http.StatusOK, map[string]string{
"webhook_url": webhookURL,
"webhook_secret": secret,
})
}
// 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
}
// reapplySSLToAllProxies updates all existing NPM proxy hosts managed by Docker Watcher
// to use the new SSL certificate. Runs in the background after settings change.
func (s *Server) reapplySSLToAllProxies(settings store.Settings) {
ctx := context.Background()
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
if err != nil {
slog.Error("reapply SSL: decrypt npm password", "error", err)
return
}
npmClient := npm.New(settings.NpmURL)
if err := npmClient.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil {
slog.Error("reapply SSL: authenticate to NPM", "error", err)
return
}
// Get all proxy hosts from NPM.
hosts, err := npmClient.ListProxyHosts(ctx)
if err != nil {
slog.Error("reapply SSL: list proxy hosts", "error", err)
return
}
// Get all our managed instances to identify which proxy hosts are ours.
projects, err := s.store.GetAllProjects()
if err != nil {
slog.Error("reapply SSL: get projects", "error", err)
return
}
// Build a set of NPM proxy IDs that belong to our instances.
managedProxyIDs := make(map[int]bool)
for _, p := range projects {
stages, err := s.store.GetStagesByProjectID(p.ID)
if err != nil {
continue
}
for _, st := range stages {
instances, err := s.store.GetInstancesByStageID(st.ID)
if err != nil {
continue
}
for _, inst := range instances {
if inst.NpmProxyID > 0 {
managedProxyIDs[inst.NpmProxyID] = true
}
}
}
}
updated := 0
for _, host := range hosts {
if !managedProxyIDs[host.ID] {
continue
}
config := npm.ProxyHostConfig{
DomainNames: host.DomainNames,
ForwardScheme: host.ForwardScheme,
ForwardHost: host.ForwardHost,
ForwardPort: host.ForwardPort,
BlockExploits: true,
AllowWebsocket: true,
HTTP2Support: true,
Meta: npm.Meta{},
Locations: []any{},
}
if settings.SSLCertificateID > 0 {
config.CertificateID = settings.SSLCertificateID
config.SSLForced = true
config.HSTSEnabled = true
} else {
config.CertificateID = 0
config.SSLForced = false
config.HSTSEnabled = false
}
if _, err := npmClient.UpdateProxyHost(ctx, host.ID, config); err != nil {
slog.Warn("reapply SSL: update proxy host failed", "host_id", host.ID, "error", err)
continue
}
updated++
}
slog.Info("reapply SSL: completed", "updated", updated, "total_managed", len(managedProxyIDs))
}
// handleDNSSettingsChange reacts to DNS configuration changes:
// - If switching to wildcard mode: remove all managed DNS records from the provider.
// - If switching provider or credentials: remove old records, create new provider, re-sync.
func (s *Server) handleDNSSettingsChange(oldProvider dns.Provider, oldSettings, newSettings store.Settings) {
ctx := context.Background()
// Step 1: If there was an old provider, remove all managed DNS records from it.
if !oldSettings.WildcardDNS && oldSettings.DNSProvider != "" && oldProvider != nil {
records, err := s.store.ListDNSRecords()
if err != nil {
slog.Error("dns settings change: list records for cleanup", "error", err)
} else {
for _, rec := range records {
if err := oldProvider.DeleteRecord(ctx, rec.FQDN); err != nil {
slog.Warn("dns settings change: delete old record", "fqdn", rec.FQDN, "error", err)
}
if err := s.store.DeleteDNSRecord(rec.FQDN); err != nil {
slog.Warn("dns settings change: remove tracking record", "fqdn", rec.FQDN, "error", err)
}
}
slog.Info("dns settings change: cleaned up old records", "count", len(records))
}
}
// Step 2: Create new provider (or nil for wildcard mode).
var newProvider dns.Provider
if !newSettings.WildcardDNS && newSettings.DNSProvider != "" {
token := newSettings.CloudflareAPIToken
if token != "" {
decrypted, err := crypto.Decrypt(s.encKey, token)
if err != nil {
slog.Error("dns settings change: decrypt token", "error", err)
return
}
token = decrypted
}
provider, err := dns.NewProvider(newSettings.DNSProvider, dns.Config{
Token: token,
ZoneID: newSettings.CloudflareZoneID,
})
if err != nil {
slog.Error("dns settings change: create provider", "error", err)
return
}
newProvider = provider
}
// Step 3: Update the server's DNS provider and notify dependents.
s.SetDNSProvider(newProvider)
if s.onDNSProviderChanged != nil {
s.onDNSProviderChanged(newProvider)
}
slog.Info("dns settings change: provider updated",
"wildcard", newSettings.WildcardDNS,
"provider", newSettings.DNSProvider)
}
// dnsTestRequest is the expected JSON body for testing DNS provider credentials.
type dnsTestRequest struct {
Provider string `json:"provider"`
Token string `json:"token"`
ZoneID string `json:"zone_id"`
}
// testDNSConnection handles POST /api/settings/dns/test.
func (s *Server) testDNSConnection(w http.ResponseWriter, r *http.Request) {
var req dnsTestRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Provider != "cloudflare" {
respondError(w, http.StatusBadRequest, "unsupported DNS provider: "+req.Provider)
return
}
token := req.Token
// If no token provided, use the stored one.
if token == "" {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
if settings.CloudflareAPIToken == "" {
respondError(w, http.StatusBadRequest, "no Cloudflare API token configured")
return
}
decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error())
return
}
token = decrypted
}
provider, err := dns.NewCloudflare(token, req.ZoneID)
if err != nil {
respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error())
return
}
if err := provider.TestConnection(r.Context()); err != nil {
respondJSON(w, http.StatusOK, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
respondJSON(w, http.StatusOK, map[string]any{
"success": true,
})
}
// dnsZonesRequest is the expected JSON body for listing DNS zones.
type dnsZonesRequest struct {
Token string `json:"token"`
}
// listDNSZones handles POST /api/settings/dns/zones.
func (s *Server) listDNSZones(w http.ResponseWriter, r *http.Request) {
var req dnsZonesRequest
if !decodeJSON(w, r, &req) {
return
}
token := req.Token
// If no token in body, use stored one.
if token == "" {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get settings: "+err.Error())
return
}
if settings.CloudflareAPIToken == "" {
respondError(w, http.StatusBadRequest, "no Cloudflare API token configured")
return
}
decrypted, err := crypto.Decrypt(s.encKey, settings.CloudflareAPIToken)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to decrypt token: "+err.Error())
return
}
token = decrypted
}
provider, err := dns.NewCloudflare(token, "")
if err != nil {
respondError(w, http.StatusBadRequest, "invalid configuration: "+err.Error())
return
}
zones, err := provider.ListZones(r.Context())
if err != nil {
respondError(w, http.StatusBadGateway, "failed to list zones: "+err.Error())
return
}
respondJSON(w, http.StatusOK, zones)
}