0405ecd9ce
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
750 lines
24 KiB
Go
750 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"`
|
|
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,
|
|
"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.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 {
|
|
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)
|
|
}
|
|
|