791cd4d6af
Build / build (push) Successful in 12m20s
Rebrand the project as Tinyforge to reflect its evolution from a Docker container watcher into a self-hosted mini CI/deployment platform. Rename covers: Go module path, Docker labels, DB/config filenames, JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend i18n, README with static sites docs, and all code comments.
764 lines
24 KiB
Go
764 lines
24 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/alexei/tinyforge/internal/crypto"
|
|
"github.com/alexei/tinyforge/internal/dns"
|
|
"github.com/alexei/tinyforge/internal/docker"
|
|
"github.com/alexei/tinyforge/internal/npm"
|
|
"github.com/alexei/tinyforge/internal/proxy"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
"github.com/alexei/tinyforge/internal/volume"
|
|
"github.com/alexei/tinyforge/internal/webhook"
|
|
)
|
|
|
|
// settingsRequest is the expected JSON body for updating settings.
|
|
type settingsRequest struct {
|
|
Domain string `json:"domain"`
|
|
ServerIP string `json:"server_ip"`
|
|
PublicIP string `json:"public_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"`
|
|
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
|
|
ImagePruneThresholdMB *int `json:"image_prune_threshold_mb,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"`
|
|
TraefikNetwork *string `json:"traefik_network,omitempty"`
|
|
TraefikAPIURL *string `json:"traefik_api_url,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,
|
|
"public_ip": settings.PublicIP,
|
|
"network": settings.Network,
|
|
"subdomain_pattern": settings.SubdomainPattern,
|
|
"notification_url": settings.NotificationURL,
|
|
"npm_url": settings.NpmURL,
|
|
"npm_email": settings.NpmEmail,
|
|
"has_npm_password": settings.NpmPassword != "",
|
|
"npm_remote": settings.NpmRemote,
|
|
"image_prune_threshold_mb": settings.ImagePruneThresholdMB,
|
|
"npm_access_list_id": settings.NpmAccessListID,
|
|
"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,
|
|
"traefik_entrypoint": settings.TraefikEntrypoint,
|
|
"traefik_cert_resolver": settings.TraefikCertResolver,
|
|
"traefik_network": settings.TraefikNetwork,
|
|
"traefik_api_url": settings.TraefikAPIURL,
|
|
"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.PublicIP != "" {
|
|
updated.PublicIP = req.PublicIP
|
|
}
|
|
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" && prov != "traefik" {
|
|
respondError(w, http.StatusBadRequest, "proxy_provider must be 'none', 'npm', or 'traefik'")
|
|
return
|
|
}
|
|
updated.ProxyProvider = prov
|
|
}
|
|
if req.ImagePruneThresholdMB != nil {
|
|
updated.ImagePruneThresholdMB = *req.ImagePruneThresholdMB
|
|
}
|
|
if req.NpmRemote != nil {
|
|
updated.NpmRemote = *req.NpmRemote
|
|
}
|
|
if req.NpmAccessListID != nil {
|
|
updated.NpmAccessListID = *req.NpmAccessListID
|
|
}
|
|
|
|
// Traefik provider settings.
|
|
if req.TraefikEntrypoint != nil {
|
|
updated.TraefikEntrypoint = *req.TraefikEntrypoint
|
|
}
|
|
if req.TraefikCertResolver != nil {
|
|
updated.TraefikCertResolver = *req.TraefikCertResolver
|
|
}
|
|
if req.TraefikNetwork != nil {
|
|
updated.TraefikNetwork = *req.TraefikNetwork
|
|
}
|
|
if req.TraefikAPIURL != nil {
|
|
updated.TraefikAPIURL = *req.TraefikAPIURL
|
|
}
|
|
|
|
// 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 proxy-affecting settings changed, re-sync all proxy routes in the background.
|
|
proxyChanged := existing.Domain != updated.Domain ||
|
|
existing.ProxyProvider != updated.ProxyProvider ||
|
|
existing.NpmRemote != updated.NpmRemote ||
|
|
existing.NpmAccessListID != updated.NpmAccessListID ||
|
|
sslChanged
|
|
if proxyChanged {
|
|
go s.resyncAllProxies(existing, 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
|
|
}
|
|
|
|
webhookPath := ""
|
|
if settings.WebhookSecret != "" {
|
|
webhookPath = "/api/webhook/" + settings.WebhookSecret
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]string{
|
|
"webhook_url": webhookPath,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
webhookURL := "/api/webhook/" + 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)
|
|
}
|
|
|
|
// listNpmAccessLists handles GET /api/settings/npm-access-lists.
|
|
// It authenticates to NPM using the stored credentials and returns all access lists.
|
|
func (s *Server) listNpmAccessLists(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
|
|
}
|
|
|
|
lists, err := client.ListAccessLists(r.Context())
|
|
if err != nil {
|
|
respondError(w, http.StatusBadGateway, "failed to list access lists: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if lists == nil {
|
|
lists = []npm.AccessList{}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, lists)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// createProxyProvider builds a proxy.Provider from the given settings.
|
|
func (s *Server) createProxyProvider(settings store.Settings) proxy.Provider {
|
|
switch settings.ProxyProvider {
|
|
case "npm":
|
|
if settings.NpmURL == "" || settings.NpmEmail == "" || settings.NpmPassword == "" {
|
|
slog.Warn("proxy resync: NPM credentials incomplete, falling back to none")
|
|
return proxy.NewNoneProvider()
|
|
}
|
|
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
|
|
if err != nil {
|
|
slog.Error("proxy resync: decrypt npm password", "error", err)
|
|
return proxy.NewNoneProvider()
|
|
}
|
|
return proxy.NewNpmProvider(npm.New(settings.NpmURL), settings.NpmEmail, npmPassword)
|
|
case "traefik":
|
|
return proxy.NewTraefikProvider(
|
|
settings.TraefikEntrypoint,
|
|
settings.TraefikCertResolver,
|
|
settings.TraefikNetwork,
|
|
settings.TraefikAPIURL,
|
|
)
|
|
default:
|
|
return proxy.NewNoneProvider()
|
|
}
|
|
}
|
|
|
|
// resyncAllProxies re-configures or removes proxy routes for all running instances
|
|
// when proxy-affecting settings change (domain, SSL cert, proxy provider).
|
|
// Runs in the background after settings save.
|
|
func (s *Server) resyncAllProxies(oldSettings, newSettings store.Settings) {
|
|
ctx := context.Background()
|
|
|
|
// Collect all proxy-enabled instances.
|
|
routes, err := s.store.ListProxyRoutes(oldSettings.Domain)
|
|
if err != nil {
|
|
slog.Error("proxy resync: list routes", "error", err)
|
|
return
|
|
}
|
|
|
|
if len(routes) == 0 {
|
|
slog.Info("proxy resync: no proxy routes to update")
|
|
return
|
|
}
|
|
|
|
providerChanged := oldSettings.ProxyProvider != newSettings.ProxyProvider
|
|
domainChanged := oldSettings.Domain != newSettings.Domain
|
|
|
|
// Step 1: If provider changed, delete old routes from the OLD provider, then switch.
|
|
if providerChanged {
|
|
slog.Info("proxy resync: provider changed", "old", oldSettings.ProxyProvider, "new", newSettings.ProxyProvider)
|
|
oldProvider := s.proxyProvider
|
|
for _, route := range routes {
|
|
if route.ProxyRouteID != "" {
|
|
if err := oldProvider.DeleteRoute(ctx, route.ProxyRouteID); err != nil {
|
|
slog.Warn("proxy resync: delete old route", "route_id", route.ProxyRouteID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create and install the new provider.
|
|
newProvider := s.createProxyProvider(newSettings)
|
|
s.SetProxyProvider(newProvider)
|
|
if s.onProxyProviderChanged != nil {
|
|
s.onProxyProviderChanged(newProvider)
|
|
}
|
|
}
|
|
|
|
// Step 2: If new provider is "none", clear all proxy route IDs and we're done.
|
|
if newSettings.ProxyProvider == "none" {
|
|
for _, route := range routes {
|
|
inst, err := s.store.GetInstanceByID(route.InstanceID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
inst.ProxyRouteID = ""
|
|
inst.NpmProxyID = 0
|
|
if err := s.store.UpdateInstance(inst); err != nil {
|
|
slog.Warn("proxy resync: clear route ID", "instance", route.InstanceID, "error", err)
|
|
}
|
|
}
|
|
slog.Info("proxy resync: cleared all proxy routes (provider set to none)", "count", len(routes))
|
|
return
|
|
}
|
|
|
|
// Step 3: Re-create/update routes with the current provider and new settings.
|
|
updated := 0
|
|
for _, route := range routes {
|
|
if route.Subdomain == "" {
|
|
continue
|
|
}
|
|
|
|
fqdn := route.Subdomain + "." + newSettings.Domain
|
|
|
|
// Reconstruct the container name (Docker DNS name) from project/stage/tag.
|
|
containerName := docker.ContainerName(route.ProjectName, route.StageName, route.ImageTag)
|
|
|
|
routeID, err := s.proxyProvider.ConfigureRoute(ctx, fqdn, containerName, route.Port, proxy.RouteOptions{
|
|
SSLCertificateID: newSettings.SSLCertificateID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("proxy resync: configure route failed",
|
|
"domain", fqdn, "instance", route.InstanceID, "error", err)
|
|
continue
|
|
}
|
|
|
|
// Update instance with new route ID.
|
|
inst, err := s.store.GetInstanceByID(route.InstanceID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
inst.ProxyRouteID = routeID
|
|
if domainChanged {
|
|
// Subdomain stays the same, but the FQDN in external systems changed.
|
|
slog.Info("proxy resync: domain updated", "instance", route.InstanceID, "domain", fqdn)
|
|
}
|
|
if err := s.store.UpdateInstance(inst); err != nil {
|
|
slog.Warn("proxy resync: update instance", "instance", route.InstanceID, "error", err)
|
|
}
|
|
updated++
|
|
}
|
|
|
|
slog.Info("proxy resync: completed", "updated", updated, "total", len(routes))
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// testNpmConnection handles POST /api/settings/npm/test.
|
|
// Tests connectivity and authentication to the NPM API.
|
|
func (s *Server) testNpmConnection(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
URL string `json:"npm_url"`
|
|
Email string `json:"npm_email"`
|
|
Password string `json:"npm_password"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
// Use provided values, fall back to stored settings.
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
slog.Error("failed to get settings", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
npmURL := req.URL
|
|
if npmURL == "" {
|
|
npmURL = settings.NpmURL
|
|
}
|
|
if npmURL == "" {
|
|
respondError(w, http.StatusBadRequest, "NPM URL is required")
|
|
return
|
|
}
|
|
|
|
email := req.Email
|
|
if email == "" {
|
|
email = settings.NpmEmail
|
|
}
|
|
|
|
password := req.Password
|
|
if password == "" && settings.NpmPassword != "" {
|
|
decrypted, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
|
|
if err != nil {
|
|
respondError(w, http.StatusBadRequest, "failed to decrypt stored NPM password")
|
|
return
|
|
}
|
|
password = decrypted
|
|
}
|
|
|
|
if email == "" || password == "" {
|
|
respondError(w, http.StatusBadRequest, "NPM email and password are required")
|
|
return
|
|
}
|
|
|
|
// Test connectivity.
|
|
client := npm.New(npmURL)
|
|
ctx := r.Context()
|
|
|
|
if err := client.Ping(ctx); err != nil {
|
|
slog.Warn("npm test: ping failed", "url", npmURL, "error", err)
|
|
respondError(w, http.StatusBadGateway, "Cannot reach NPM at "+npmURL)
|
|
return
|
|
}
|
|
|
|
// Test authentication.
|
|
if err := client.Authenticate(ctx, email, password); err != nil {
|
|
slog.Warn("npm test: auth failed", "url", npmURL, "error", err)
|
|
respondError(w, http.StatusBadGateway, "NPM authentication failed — check email and password")
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|