Files
tiny-forge/internal/api/settings.go
alexei.dolgolyov d8ab22876f
Build / build (push) Successful in 10m41s
refactor(workload): extract Instance entirely; Container is canonical
End-to-end extraction of the Instance concept. After this commit:

  * internal/store/instances.go — DELETED
  * internal/store/models.go — Instance struct gone, ProxyRoute moved here
  * containers table is the single source of truth for project/stack/site
    container state. instances table is dropped via DROP TABLE migration
    (idempotent; re-runnable on every boot).
  * Legacy tinyforge.project / tinyforge.stage / tinyforge.instance-id
    Docker labels are no longer emitted; only tinyforge.workload.{id,kind},
    tinyforge.role, and tinyforge.managed are stamped on new containers.

Backend rewrites:
  - internal/deployer:        executeDeploy + blueGreenDeploy + rollback +
                              promote use store.Container natively. New
                              removeContainer() replaces removeInstance().
                              enforceMaxInstances reads via
                              ListContainersByStageID.
  - internal/reconciler:      legacy tinyforge.instance-id dispatch removed;
                              upsertByWorkloadLabel now finds existing rows
                              by docker container ID first and falls back to
                              the deterministic workloadID:role key.
  - internal/stale/scanner:   Scan + new FindStaleContainers walk the
                              containers table; emit StaleContainer JSON.
  - internal/stats/collector: ListContainers replaces ListAllInstances.
  - internal/webhook/handler: workload-secret lookup tried first; falls back
                              to project / static_site secret column.
  - internal/api: instances.go, stale.go, stats.go, stats_history.go,
                  projects.go, settings.go, docker.go, dns.go all read /
                  write through Container.

Docker layer:
  - ManagedContainer exposes WorkloadID/Kind/Role from the canonical labels.
  - ListContainers filters by tinyforge.managed=true.
  - Network creation uses LabelManaged instead of LabelProject.

Frontend:
  - Instance type is now a Container alias; .status → .state,
    .last_alive_at → .last_seen_at.
  - InstanceCard takes stageId as a prop (no longer derived from Instance).
  - StaleContainer JSON shape rewritten: { container, workload_name, role,
    days_stale }. StaleContainerCard + /containers/stale page updated.
  - ProjectCard / homepage / SystemHealthCard filter by .state.

The migration loop now tolerates "no such table" alongside "duplicate
column" / "already exists" so obsolete ALTER TABLE entries targeting the
dropped instances table no-op cleanly on first boot.

Tests: store + deployer + reconciler + webhook + staticsite + notify all
still pass. Frontend svelte-check: zero errors.
2026-05-09 14:43:12 +03:00

754 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"
)
// 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"`
AutoBackupBeforeDeploy *bool `json:"auto_backup_before_deploy,omitempty"`
StatsIntervalSeconds *int `json:"stats_interval_seconds,omitempty"`
StatsRetentionHours *int `json:"stats_retention_hours,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,
"has_notification_secret": settings.NotificationSecret != "",
"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,
"auto_backup_before_deploy": settings.AutoBackupBeforeDeploy,
"stats_interval_seconds": settings.StatsIntervalSeconds,
"stats_retention_hours": settings.StatsRetentionHours,
"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 req.AutoBackupBeforeDeploy != nil {
updated.AutoBackupBeforeDeploy = *req.AutoBackupBeforeDeploy
}
if req.StatsIntervalSeconds != nil {
v := *req.StatsIntervalSeconds
if v != 0 && (v < 5 || v > 300) {
respondError(w, http.StatusBadRequest, "stats_interval_seconds must be 0 (disabled) or between 5 and 300")
return
}
updated.StatsIntervalSeconds = v
}
if req.StatsRetentionHours != nil {
v := *req.StatsRetentionHours
if v < 0 || v > 24 {
respondError(w, http.StatusBadRequest, "stats_retention_hours must be between 0 and 24")
return
}
updated.StatsRetentionHours = v
}
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"})
}
// 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 {
c, err := s.store.GetContainerByID(route.InstanceID)
if err != nil {
continue
}
c.ProxyRouteID = ""
c.NpmProxyID = 0
if err := s.store.UpdateContainer(c); err != nil {
slog.Warn("proxy resync: clear route ID", "container", 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 container row with new route ID.
c, err := s.store.GetContainerByID(route.InstanceID)
if err != nil {
continue
}
c.ProxyRouteID = routeID
if domainChanged {
slog.Info("proxy resync: domain updated", "container", route.InstanceID, "domain", fqdn)
}
if err := s.store.UpdateContainer(c); err != nil {
slog.Warn("proxy resync: update container", "container", 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)
}