a9c7775bb7
Add backup/restore functionality for the SQLite database. Users can trigger manual backups, configure automatic backups on an interval with retention policies, list/download/delete backups, and restore from any backup. - Backup engine using VACUUM INTO (safe with WAL mode) - Backup metadata tracked in DB, files stored in DATA_DIR/backups/ - Settings: backup_enabled, backup_interval_hours, backup_retention_count - API: POST/GET/DELETE /api/backups, download, restore endpoints - Autobackup via cron scheduler with configurable interval - Retention: prune on startup, after each backup (manual and auto) - Orphan cleanup: removes backup files without metadata on startup - Restore: replaces DB and triggers graceful server shutdown - Settings UI: /settings/backup with toggle, interval, retention config - Backup list with download, delete, restore actions - i18n: English and Russian translations
563 lines
17 KiB
Go
563 lines
17 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"`
|
|
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,
|
|
"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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|