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.
582 lines
18 KiB
Go
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)
|
|
}
|
|
|