Ctrl K
ComunidadGuías…

internal/server/dns_handlers.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	appdb "github.com/X4Applegate/caddyui/internal/db"
	"github.com/X4Applegate/caddyui/internal/dns"
	"github.com/X4Applegate/caddyui/internal/models"
	"github.com/go-chi/chi/v5"
)

// --- Unified DNS helpers ---
//
// Replaces the v2.2.x per-provider helpers (cfClient/cfCreateDNSRecord/...,
// pbClient/pbCreateDNSRecord/...) with a single code path driven by the
// dns.Provider registry. A proxy_hosts row carries four DNS columns:
//   dns_provider  — the provider ID ("cloudflare", "porkbun", ...)
//   dns_zone_id   — the provider-native zone ID (opaque for CF/Hetzner/Route53;
//                   domain name for PB/DO/GD/NC)
//   dns_zone_name — the base domain in human-readable form ("example.com")
//   dns_record_id — the record identifier returned by the provider
// All mutations happen through dnsCreateRecord / dnsDeleteRecord /
// dnsUpdateAllRecords — no provider-specific branch lives above this line.

// dnsCreateRecordForFQDN creates an A record at fqdn pointing at the
// per-server public IP and returns (recordID, resolvedZoneName). Returns
// ("","") on any precondition failure or provider error; all failures are
// logged but not returned — DNS is non-fatal to the caller's main save
// path. Shared by the proxy-host and raw-route create paths; the caller
// persists the returned record ID via its own Update*DNSRecord helper.
//
// serverID is the Caddy server the caller lives on — v2.4.0 reads the
// per-server public_ip first and falls back to the global setting so
// multi-server setups get the right A-record content instead of always
// pointing at server #1's IP.
func (s *Server) dnsCreateRecordForFQDN(serverID int64, provider, profileID, zoneID, zoneName, fqdn string) (string, string) {
	if provider == "" || zoneID == "" || fqdn == "" {
		return "", ""
	}
	client := s.dnsClientFor(provider, profileID)
	if client == nil {
		return "", ""
	}
	ip := s.serverIPFor(serverID)
	if ip == "" {
		log.Printf("DNS: server IP not configured for server %d — skipping record creation for %s", serverID, fqdn)
		return "", ""
	}
	zone := dns.Zone{ID: zoneID, Name: zoneName}
	if zone.Name == "" {
		// Older rows (pre-migration) may not have zone_name populated —
		// it's optional metadata for most providers. Fall back to the
		// zone ID which doubles as the domain for PB/DO/GD/NC.
		zone.Name = zoneID
	}
	// v2.4.7: honour the per-provider zone allow-list as a last-line guard.
	// The dropdown is already filtered, but the zone name on the row could
	// have come from an older config / direct DB edit / a user who
	// tightened the allow-list after the host was created. Refusing here
	// makes the allow-list a hard safety rail, not just a UI filter.
	if !s.zoneAllowedFor(provider, profileID, zone.Name) {
		log.Printf("DNS %s: zone %q not in allow-list — skipping record creation for %s", provider, zone.Name, fqdn)
		return "", ""
	}
	rec, err := client.CreateRecord(zone, fqdn, ip, "A", 0)
	if err != nil {
		log.Printf("DNS %s: create record for %s: %v", provider, fqdn, err)
		return "", ""
	}
	return rec.ID, zone.Name
}

// splitDNSRecordIDs splits the comma-separated DNSRecordID column value into
// individual provider record IDs. v2.5.9 introduced the comma-separated
// encoding so routes with multiple hostnames (e.g. `example.com, *.example.com`)
// can track one provider record per hostname. Empty / single-ID values still
// parse cleanly — a pre-v2.5.9 row with "abc123" returns []string{"abc123"}.
func splitDNSRecordIDs(csv string) []string {
	if csv == "" {
		return nil
	}
	parts := strings.Split(csv, ",")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

// dnsCreateRecord creates an A record for every domain on the proxy host.
// p.DNSProvider / p.DNSZoneID must be set before calling. The set of
// provider-returned record IDs is persisted to the proxy_hosts row as a
// comma-separated string via models.UpdateProxyHostDNSRecord.
// v2.5.9: previously only the first domain got a record — multi-domain hosts
// (aliases + primary, or wildcard + apex) now get one record per hostname so
// clients resolving any alias actually reach the origin.
func (s *Server) dnsCreateRecord(serverID, hostID int64, p *models.ProxyHost) {
	domains := p.DomainList()
	if len(domains) == 0 {
		return
	}
	var ids []string
	var zname string
	for _, fqdn := range domains {
		recID, zn := s.dnsCreateRecordForFQDN(serverID, p.DNSProvider, p.DNSProfileID, p.DNSZoneID, p.DNSZoneName, fqdn)
		if recID == "" {
			continue
		}
		ids = append(ids, recID)
		if zname == "" {
			zname = zn
		}
	}
	if len(ids) == 0 {
		return
	}
	if err := models.UpdateProxyHostDNSRecord(s.DB, hostID, p.DNSProvider, p.DNSZoneID, zname, strings.Join(ids, ",")); err != nil {
		log.Printf("DNS %s: store record IDs for host %d: %v", p.DNSProvider, hostID, err)
	}
}

// dnsCreateRecordForRedirection — v2.12.2: creates an A record per hostname
// in the redirection host's Domains CSV, mirroring dnsCreateRecord on the
// proxy-host side. Persists the comma-separated provider IDs to the
// redirection_hosts row via UpdateRedirectionHostDNSRecord.
func (s *Server) dnsCreateRecordForRedirection(serverID, hostID int64, rh *models.RedirectionHost) {
	domains := rh.DomainList()
	if len(domains) == 0 {
		return
	}
	var ids []string
	var zname string
	for _, fqdn := range domains {
		recID, zn := s.dnsCreateRecordForFQDN(serverID, rh.DNSProvider, rh.DNSProfileID, rh.DNSZoneID, rh.DNSZoneName, fqdn)
		if recID == "" {
			continue
		}
		ids = append(ids, recID)
		if zname == "" {
			zname = zn
		}
	}
	if len(ids) == 0 {
		return
	}
	if err := models.UpdateRedirectionHostDNSRecord(s.DB, hostID, rh.DNSProvider, rh.DNSZoneID, zname, strings.Join(ids, ",")); err != nil {
		log.Printf("DNS %s: store record IDs for redirection %d: %v", rh.DNSProvider, hostID, err)
	}
}

// dnsCreateRecordForRaw is the raw-route twin of dnsCreateRecord.
// v2.5.6: raw routes don't carry a Domains CSV, so hostnames are pulled
// from the JSON blob's match.host[] via rawRouteHosts.
// v2.5.9: creates a record for EVERY hostname in match[].host[] rather than
// only the first — so an advanced route covering `example.com, *.example.com`
// gets both A records provisioned. Record IDs are persisted to the raw_routes
// row as a comma-separated string via models.UpdateRawRouteDNSRecord. No-op
// when DNS fields are unset or the JSON has no host matcher.
func (s *Server) dnsCreateRecordForRaw(serverID, routeID int64, rr *models.RawRoute) {
	hosts := rawRouteHosts(*rr)
	if len(hosts) == 0 {
		return
	}
	var ids []string
	var zname string
	for _, fqdn := range hosts {
		recID, zn := s.dnsCreateRecordForFQDN(serverID, rr.DNSProvider, rr.DNSProfileID, rr.DNSZoneID, rr.DNSZoneName, fqdn)
		if recID == "" {
			continue
		}
		ids = append(ids, recID)
		if zname == "" {
			zname = zn
		}
	}
	if len(ids) == 0 {
		return
	}
	if err := models.UpdateRawRouteDNSRecord(s.DB, routeID, rr.DNSProvider, rr.DNSZoneID, zname, strings.Join(ids, ",")); err != nil {
		log.Printf("DNS %s: store record IDs for raw route %d: %v", rr.DNSProvider, routeID, err)
	}
}

// dnsDeleteRecord removes previously-created records for the given row.
// v2.5.9: recordIDs may be a single ID ("abc123") or a comma-separated list
// ("abc123,def456") — loops internally so callers can keep passing the raw
// DNSRecordID column value regardless of how many records are behind it.
// Best-effort: errors are logged, not returned (the row is being deleted
// anyway; a leftover record is a minor annoyance, not a correctness issue).
func (s *Server) dnsDeleteRecord(providerID, profileID, zoneID, zoneName, recordIDs string) {
	if providerID == "" || recordIDs == "" {
		return
	}
	client := s.dnsClientFor(providerID, profileID)
	if client == nil {
		return
	}
	zone := dns.Zone{ID: zoneID, Name: zoneName}
	if zone.Name == "" {
		zone.Name = zoneID
	}
	// v2.4.7: refuse to delete records in zones the allow-list excludes.
	// Intentionally symmetric with dnsCreateRecord — "touching a record"
	// in an excluded zone is exactly what the allow-list is meant to
	// prevent, even when the touch is a cleanup. Leaves the record in
	// place; the user can remove it by hand via the provider's console.
	if !s.zoneAllowedFor(providerID, profileID, zone.Name) {
		log.Printf("DNS %s: zone %q not in allow-list — leaving records %s in place", providerID, zone.Name, recordIDs)
		return
	}
	for _, recordID := range splitDNSRecordIDs(recordIDs) {
		if err := client.DeleteRecord(zone, recordID); err != nil {
			log.Printf("DNS %s: delete record %s: %v", providerID, recordID, err)
		}
	}
}

// dnsUpdateAllRecords retargets managed DNS records at newIP. Pass
// serverID > 0 to scope the retarget to content rows that live on that
// Caddy server (v2.4.0 per-server public-IP flow); 0 retargets every
// managed record regardless of server (used by the legacy global-IP
// fallback path so pre-v2.4.0 databases still work).
//
// We cache provider clients by ID so we build each at most once per call.
// Records for providers with missing credentials are skipped rather than
// cleared, so partial credential removal doesn't destroy working records.
//
// v2.5.6: also retargets raw-route records alongside proxy-host records.
func (s *Server) dnsUpdateAllRecords(serverID int64, newIP string) {
	hosts, err := models.ListProxyHostsWithDNSRecords(s.DB, serverID)
	if err != nil {
		log.Printf("DNS: list managed hosts for IP update: %v", err)
	}
	rawRoutes, err := models.ListRawRoutesWithDNSRecords(s.DB, serverID)
	if err != nil {
		log.Printf("DNS: list managed raw routes for IP update: %v", err)
	}
	if len(hosts) == 0 && len(rawRoutes) == 0 {
		return
	}
	clients := map[string]dns.Provider{}
	getClient := func(id, profileID string) dns.Provider {
		key := id + "|" + profileID
		if c, ok := clients[key]; ok {
			return c
		}
		c := s.dnsClientFor(id, profileID)
		clients[key] = c
		return c
	}
	log.Printf("DNS: retargeting %d proxy-host record(s) + %d raw-route record(s) to %s", len(hosts), len(rawRoutes), newIP)

	// retarget is the shared delete-then-create worker. kind is a tag for
	// the log line ("proxy"/"raw"); persist is the row-specific updater
	// that writes the fresh record ID(s) (or clears on failure). fqdns are
	// pulled differently per row type (Domains CSV vs match[].host[]), so
	// the caller hands them in.
	// v2.5.9: accepts multiple fqdns + multiple old record IDs to match
	// the multi-hostname create path. Deletes every old record, creates
	// one new record per current hostname, persists the joined ID list.
	// Rows created pre-v2.5.9 (one ID, multiple hostnames) self-heal on
	// the first retarget — the missing-alias records get created fresh.
	retarget := func(kind string, rowID int64, provider, profileID, zoneID, zoneName, recordIDs string, fqdns []string, persist func(zoneID, zoneName, recordID string)) {
		client := getClient(provider, profileID)
		if client == nil {
			log.Printf("DNS %s: credentials missing — skipping %s %d retarget", provider, kind, rowID)
			return
		}
		if len(fqdns) == 0 {
			return
		}
		zone := dns.Zone{ID: zoneID, Name: zoneName}
		if zone.Name == "" {
			zone.Name = zoneID
		}
		// v2.4.7: guard IP retargets against the allow-list too. If a zone
		// was valid when the record was created but the user has since
		// tightened the list, the retarget job leaves that record alone —
		// same policy as dnsCreateRecord / dnsDeleteRecord.
		if !s.zoneAllowedFor(provider, profileID, zone.Name) {
			log.Printf("DNS %s: zone %q not in allow-list — skipping retarget for %v", provider, zone.Name, fqdns)
			return
		}
		// Delete-then-create semantic. Every provider implementation is
		// idempotent on delete (silently succeeds if the record is gone),
		// and recreate gives us a fresh record ID — cleanest across the
		// whole provider set, even if a tiny window exists where the
		// record is absent. Users are already dealing with a live IP
		// change when this runs; a few seconds of DNS flutter is noise.
		for _, recordID := range splitDNSRecordIDs(recordIDs) {
			if err := client.DeleteRecord(zone, recordID); err != nil {
				log.Printf("DNS %s: delete old record %s: %v", provider, recordID, err)
			}
		}
		var newIDs []string
		for _, fqdn := range fqdns {
			rec, err := client.CreateRecord(zone, fqdn, newIP, "A", 0)
			if err != nil {
				log.Printf("DNS %s: create new record for %s: %v", provider, fqdn, err)
				continue
			}
			newIDs = append(newIDs, rec.ID)
			log.Printf("DNS %s: updated %s → %s (record %s)", provider, fqdn, newIP, rec.ID)
		}
		if len(newIDs) == 0 {
			persist("", "", "")
			return
		}
		persist(zone.ID, zone.Name, strings.Join(newIDs, ","))
	}

	for _, h := range hosts {
		h := h
		retarget("proxy", h.ID, h.DNSProvider, h.DNSProfileID, h.DNSZoneID, h.DNSZoneName, h.DNSRecordID,
			h.DomainList(),
			func(zoneID, zoneName, recordID string) {
				provider := h.DNSProvider
				if recordID == "" {
					provider = ""
				}
				_ = models.UpdateProxyHostDNSRecord(s.DB, h.ID, provider, zoneID, zoneName, recordID)
			})
	}
	for _, rr := range rawRoutes {
		rr := rr
		retarget("raw", rr.ID, rr.DNSProvider, rr.DNSProfileID, rr.DNSZoneID, rr.DNSZoneName, rr.DNSRecordID,
			rawRouteHosts(rr),
			func(zoneID, zoneName, recordID string) {
				provider := rr.DNSProvider
				if recordID == "" {
					provider = ""
				}
				_ = models.UpdateRawRouteDNSRecord(s.DB, rr.ID, provider, zoneID, zoneName, recordID)
			})
	}
}

// apiDNSZones returns the list of zones for the provider specified in the
// ?provider= query string. Replaces the per-provider apiCFZones /
// apiPBDomains endpoints with a single handler.
//
// Response shape is always [{id, name}] so the form's zone-picker JS
// renders every provider with the same code path. Cloudflare, Hetzner, and Route 53
// return real opaque zone IDs; for Porkbun/DO/GoDaddy/Namecheap the ID
// and name are the same string (the bare domain).
func (s *Server) apiDNSZones(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	provider := strings.ToLower(strings.TrimSpace(q.Get("provider")))
	profileID := strings.TrimSpace(q.Get("profile"))
	if profileID == "" {
		profileID = strings.TrimSpace(q.Get("dns_profile_id"))
	}
	provider, profileID = s.normalizeDNSFormSelection(provider, profileID)
	w.Header().Set("Content-Type", "application/json")
	if provider == "" {
		w.WriteHeader(http.StatusBadRequest)
		_ = json.NewEncoder(w).Encode(map[string]string{"error": "missing provider parameter"})
		return
	}
	client := s.dnsClientFor(provider, profileID)
	if client == nil {
		w.WriteHeader(http.StatusBadRequest)
		_ = json.NewEncoder(w).Encode(map[string]string{"error": "credentials for provider \"" + provider + "\" not configured in Settings"})
		return
	}
	zones, err := client.ListZones()
	if err != nil {
		w.WriteHeader(http.StatusBadGateway)
		_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
		return
	}
	// v2.4.7: apply the per-provider zone allow-list. If it's set, only
	// zones whose name is on the list are returned — the proxy-host form's
	// zone dropdown then literally cannot offer domains the user has
	// excluded. Empty allow-list = unrestricted (every zone the credentials
	// can see), which preserves the original behaviour.
	if allow := s.zoneAllowlistFor(provider, profileID); len(allow) > 0 {
		allowSet := make(map[string]struct{}, len(allow))
		for _, a := range allow {
			allowSet[a] = struct{}{}
		}
		filtered := zones[:0]
		for _, z := range zones {
			name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(z.Name)), ".")
			if _, ok := allowSet[name]; ok {
				filtered = append(filtered, z)
			}
		}
		zones = filtered
	}
	_ = json.NewEncoder(w).Encode(zones)
}

// apiDNSCheckRecord looks up existing records for an (provider, zone, fqdn)
// triple so the proxy-host form can warn when saving would collide with
// something already in DNS. Called by the form JS after the user has picked
// provider + zone and typed a first domain.
//
// Query string: provider=<id>&zone=<zoneID>&zone_name=<zoneName>&fqdn=<host>
//
// Response shape (always 200 when inputs are valid — errors go in the body):
//
//	{"ok":true, "exists":true, "records":[{id,type,name,content,ttl},...]}
//	{"ok":true, "exists":false}
//	{"ok":false, "error":"..."}
//
// The `exists` flag is the only thing the UI needs to branch on; the full
// record list is included so the warning dialog can show the user what
// they're about to clobber. 200-with-body-error (rather than 4xx/5xx) keeps
// the form JS simple — one JSON parse, branch on `ok`.
func (s *Server) apiDNSCheckRecord(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	q := r.URL.Query()
	provider := strings.ToLower(strings.TrimSpace(q.Get("provider")))
	profileID := strings.TrimSpace(q.Get("profile"))
	if profileID == "" {
		profileID = strings.TrimSpace(q.Get("dns_profile_id"))
	}
	provider, profileID = s.normalizeDNSFormSelection(provider, profileID)
	zoneID := strings.TrimSpace(q.Get("zone"))
	zoneName := strings.TrimSpace(q.Get("zone_name"))
	fqdn := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(q.Get("fqdn"))), ".")
	if provider == "" || zoneID == "" || fqdn == "" {
		_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "missing provider/zone/fqdn"})
		return
	}
	// Zone name is optional on the wire — most providers use ID==Name so
	// we can recover it. Cloudflare, Hetzner, and Route 53 have opaque IDs, so when
	// the client omits zone_name we fall back to zoneID (which will fail
	// SubdomainOf but still works for Cloudflare's server-side filter).
	if zoneName == "" {
		zoneName = zoneID
	}
	client := s.dnsClientFor(provider, profileID)
	if client == nil {
		_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "credentials for provider \"" + provider + "\" not configured"})
		return
	}
	// Allow-list guard: don't leak record listings for zones the user has
	// excluded from management. Symmetrical with apiDNSZones' filter.
	if !s.zoneAllowedFor(provider, profileID, zoneName) {
		_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "zone not in allow-list"})
		return
	}
	zone := dns.Zone{ID: zoneID, Name: zoneName}
	records, err := client.FindRecord(zone, fqdn)
	if err != nil {
		_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error()})
		return
	}
	// Scope the warning to record types that actually conflict with the
	// A/AAAA/CNAME we're about to write. MX / TXT / SRV / CAA routinely
	// cohabit with the web endpoint (email, SPF, cert issuance) and
	// alarming on them would scare users into clicking Override — which
	// used to wipe their mail records. See IsProxyConflictingType.
	filtered := records[:0]
	for _, rec := range records {
		if dns.IsProxyConflictingType(rec.Type) {
			filtered = append(filtered, rec)
		}
	}
	_ = json.NewEncoder(w).Encode(map[string]any{
		"ok":      true,
		"exists":  len(filtered) > 0,
		"records": filtered,
	})
}

// ─────────────────────────────────────────────────────────────────────
// v2.5.2: post-save "deploying" flow.
// ─────────────────────────────────────────────────────────────────────
//
// When a proxy-host save creates or changes a managed-DNS A record, we
// redirect to /proxy-hosts/{id}/deploying instead of bouncing straight
// back to the list. That page polls apiProxyHostDeployStatus every few
// seconds and draws a live checklist — DNS propagated, TLS handshake
// succeeded — so the user actually knows when their site is enterable.
// ─────────────────────────────────────────────────────────────────────

// apiProxyHostDeployStatus returns the real-time deployment status for a
// freshly-saved proxy host. Response:
//
//	{
//	  "fqdn":         "test.example.com",
//	  "expected_ip":  "203.0.113.10",
//	  "resolved_ips": ["203.0.113.10"],
//	  "ssl_enabled":  true,
//	  "proxied":      false,
//	  "dns_ready":    true,
//	  "cert_ready":   false,
//	  "error":        ""
//	}
//
// For non-proxied records dns_ready is true only when a public resolver
// returns an A record matching the server's configured public IP. For
// Cloudflare-proxied records (orange cloud) the record points at CF's
// edge IPs, so we relax to "any A record" — the record is live in DNS
// and CF is handling the rest. Cert check is a plain TLS handshake via
// tls.Dial against fqdn:443 with system-trust verification enabled, so
// a Caddy-internal self-signed fallback correctly reports not-ready.
func (s *Server) apiProxyHostDeployStatus(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if err != nil {
		_ = json.NewEncoder(w).Encode(map[string]any{"error": "bad id"})
		return
	}
	host, err := models.GetProxyHost(s.DB, id)
	if err != nil || host == nil {
		w.WriteHeader(http.StatusNotFound)
		_ = json.NewEncoder(w).Encode(map[string]any{"error": "not found"})
		return
	}
	// Ownership: non-admins can only poll their own hosts.
	cu := s.currentUser(r)
	if cu != nil && cu.Role != models.RoleAdmin {
		if !s.canManageOwned(cu, host.OwnerID) {
			w.WriteHeader(http.StatusForbidden)
			_ = json.NewEncoder(w).Encode(map[string]any{"error": "forbidden"})
			return
		}
	}
	fqdn := dns.FirstDomain(host.Domains)
	expectedIP := s.serverIPFor(host.ServerID)
	resp := map[string]any{
		"fqdn":         fqdn,
		"expected_ip":  expectedIP,
		"ssl_enabled":  host.SSLEnabled,
		"proxied":      false,
		"dns_ready":    false,
		"cert_ready":   false,
		"resolved_ips": []string{},
		"dns_skipped":  host.DNSProvider != "" && host.DNSSkipRecord,
	}
	if host.DNSProvider != "" && host.DNSSkipRecord {
		resp["dns_ready"] = true
	}
	if fqdn == "" {
		resp["error"] = "host has no domain"
		_ = json.NewEncoder(w).Encode(resp)
		return
	}
	// Proxied flag applies to Cloudflare only; other providers always
	// resolve to the configured server IP.
	if host.DNSProvider == dns.Cloudflare {
		if v, _ := models.GetSetting(s.DB, settingCFProxied); v == "1" {
			resp["proxied"] = true
		}
	}
	// DNS check via Cloudflare DNS-over-HTTPS. Silent on error — the
	// client keeps polling, and a transient DoH failure should just
	// look like "not ready yet".
	if !host.DNSSkipRecord {
		if ips, dnsErr := s.resolveVerifyA(fqdn); dnsErr == nil {
			resp["resolved_ips"] = ips
			if resp["proxied"] == true {
				resp["dns_ready"] = len(ips) > 0
			} else if expectedIP != "" {
				for _, ip := range ips {
					if ip == expectedIP {
						resp["dns_ready"] = true
						break
					}
				}
			} else {
				// No expected IP configured — fall back to "any A record".
				// Otherwise we'd always report not-ready.
				resp["dns_ready"] = len(ips) > 0
			}
		}
	}
	// Cert check: skipped when SSL is off on the host, and deferred
	// until DNS is ready (otherwise the dial is guaranteed to fail on
	// hostname resolution).
	resolvedIPs, _ := resp["resolved_ips"].([]string)
	proxied, _ := resp["proxied"].(bool)
	if !host.SSLEnabled {
		resp["cert_ready"] = true
	} else if resp["dns_ready"] == true {
		resp["cert_ready"] = s.tlsHandshakeOK(host.ServerID, fqdn, proxied, resolvedIPs)
	}
	_ = json.NewEncoder(w).Encode(resp)
}

// verifyResolverMode classifies the admin-configured verification resolver.
type verifyResolverMode int

const (
	verifyResolverDefault  verifyResolverMode = iota // Cloudflare DoH
	verifyResolverDoHURL                             // custom DoH JSON endpoint
	verifyResolverPlainDNS                           // one or more host:port DNS servers
)

// classifyVerifyResolver interprets the dns_verify_resolver setting (issue #98).
// Blank -> default (Cloudflare DoH). A value starting with http(s):// is a
// custom DoH endpoint. Otherwise it is a comma/space/newline-separated list of
// plain DNS servers; a bare host gets the default :53 port.
func classifyVerifyResolver(raw string) (verifyResolverMode, []string) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return verifyResolverDefault, nil
	}
	if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
		return verifyResolverDoHURL, []string{raw}
	}
	servers := parsePlainDNSServers(raw)
	if len(servers) == 0 {
		return verifyResolverDefault, nil
	}
	return verifyResolverPlainDNS, servers
}

// parsePlainDNSServers splits raw on comma/space/newline/tab into "host:port"
// DNS server addresses, defaulting to port 53 when none is given. Shared by
// the verification-resolver (issue #98) and ACME DNS-01 resolver (issue #116)
// settings, which both accept the same plain-DNS-list syntax.
func parsePlainDNSServers(raw string) []string {
	var servers []string
	for _, f := range strings.FieldsFunc(raw, func(r rune) bool {
		return r == ',' || r == ' ' || r == '\n' || r == '\t'
	}) {
		f = strings.TrimSpace(f)
		if f == "" {
			continue
		}
		if !strings.Contains(f, ":") {
			f += ":53"
		}
		servers = append(servers, f)
	}
	return servers
}

// acmeDNSResolvers returns the admin-configured DNS servers (issue #116) that
// Caddy's own ACME DNS-01 solver should query for TXT-record propagation
// checks, instead of the container's default resolver. Returns nil when
// unset, so Caddy's own default applies.
func (s *Server) acmeDNSResolvers() []string {
	raw, _ := models.GetSetting(s.DB, settingACMEDNSResolvers)
	return parsePlainDNSServers(raw)
}

// resolveVerifyA resolves A records for fqdn using the admin-configured
// verification resolver (issue #98), falling back to Cloudflare DoH. Used by
// the deploy/readiness checks so filtered or split-horizon networks can point
// verification at a reachable resolver instead of hanging on Cloudflare.
func (s *Server) resolveVerifyA(fqdn string) ([]string, error) {
	raw, _ := models.GetSetting(s.DB, settingDNSVerifyResolver)
	switch mode, detail := classifyVerifyResolver(raw); mode {
	case verifyResolverDoHURL:
		return resolveViaDoHURL(detail[0], fqdn)
	case verifyResolverPlainDNS:
		return resolveViaPlainDNS(detail, fqdn)
	default:
		return resolveViaDoH(fqdn)
	}
}

// resolveViaDoH queries Cloudflare DNS-over-HTTPS for A records for fqdn.
func resolveViaDoH(fqdn string) ([]string, error) {
	return resolveViaDoHURL("https://cloudflare-dns.com/dns-query", fqdn)
}

// resolveViaDoHURL queries any DoH JSON-API endpoint for A records for fqdn.
// Returns the list of IPs, or an empty slice if the record doesn't exist yet.
// 6-second timeout so a slow resolver doesn't stall the poll.
func resolveViaDoHURL(base, fqdn string) ([]string, error) {
	sep := "?"
	if strings.Contains(base, "?") {
		sep = "&"
	}
	req, err := http.NewRequest("GET", base+sep+"name="+url.QueryEscape(fqdn)+"&type=A", nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/dns-json")
	client := &http.Client{Timeout: 6 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var data struct {
		Answer []struct {
			Type int    `json:"type"`
			Data string `json:"data"`
		} `json:"Answer"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
		return nil, err
	}
	ips := make([]string, 0, len(data.Answer))
	for _, a := range data.Answer {
		if a.Type == 1 { // A record
			ips = append(ips, strings.TrimSpace(a.Data))
		}
	}
	return ips, nil
}

// plainDNSLookup is the per-server A lookup, indirected so failover ordering
// can be tested without real network I/O.
var plainDNSLookup = lookupAFromServer

// resolveViaPlainDNS resolves A records for fqdn by querying the given DNS
// servers directly, trying each in order until one actually answers. Failover
// is decided by the DNS exchange itself, not by whether a socket could be
// opened: a UDP "dial" to an unreachable server succeeds immediately without
// touching the network, so relying on dial errors would pin every query to a
// dead first server (issue #98). A server that authoritatively reports "no such
// host" ends the search and returns an empty slice — the same "record not live
// yet" signal the DoH path gives — so the poll keeps going.
func resolveViaPlainDNS(servers []string, fqdn string) ([]string, error) {
	var lastErr error
	for _, srv := range servers {
		ips, notFound, err := plainDNSLookup(srv, fqdn)
		if notFound {
			return []string{}, nil
		}
		if err == nil {
			return ips, nil
		}
		lastErr = err // unreachable / timeout — try the next server
	}
	if lastErr == nil {
		lastErr = fmt.Errorf("no DNS servers configured")
	}
	return nil, lastErr
}

// lookupAFromServer queries a single DNS server for A records for fqdn. It
// returns notFound=true when the server answered but has no such record (a
// definitive answer, not a reason to fail over), or a non-nil err when the
// server did not answer in time (unreachable/filtered — try the next one).
func lookupAFromServer(server, fqdn string) (ips []string, notFound bool, err error) {
	r := &net.Resolver{
		PreferGo: true,
		Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
			d := net.Dialer{Timeout: 3 * time.Second}
			return d.DialContext(ctx, network, server)
		},
	}
	ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
	defer cancel()
	addrs, lerr := r.LookupIP(ctx, "ip4", fqdn)
	if lerr != nil {
		if dnsErr, ok := lerr.(*net.DNSError); ok && dnsErr.IsNotFound {
			return []string{}, true, nil
		}
		return nil, false, lerr
	}
	out := make([]string, 0, len(addrs))
	for _, ip := range addrs {
		out = append(out, ip.String())
	}
	return out, false, nil
}

// tlsHandshakeOK performs a full TLS handshake with SNI set to fqdn and
// validates the returned chain against the system trust store. Returns
// true only when the cert chain verifies — which is what users actually
// care about ("can I open the site in a browser"). A Caddy-internal
// self-signed fallback cert, an ACME-staging cert, or an expired cert
// all correctly report false.
//
// Dial target depends on how the record is served, to avoid the WAN
// hairpin NAT problem most consumer routers have (resolving fqdn to the
// public IP and dialing it from inside the LAN fails even when the site
// is live for real users). We pick the dial target in priority order:
//
//  1. **Cloudflare-proxied** (orange cloud): dial the CF edge IP we just
//     resolved via DoH. CF edge IPs are always public + outside the LAN,
//     so hairpin never applies; SNI = fqdn makes CF serve the right
//     customer cert from its Universal SSL / Advanced Certs pool. This
//     is v2.5.5 — previously we tried to dial Caddy internally, which
//     is wrong for proxied hosts because the user's browser sees CF's
//     cert, not Caddy's origin cert.
//  2. **Direct**: dial the Caddy server by its admin-URL hostname
//     (docker service name `caddy` for single-host, admin host for
//     remote servers). Bypasses public DNS + WAN hairpin; SNI = fqdn
//     makes Caddy serve the right cert. This is the v2.5.4 path and
//     remains the default for non-proxied providers.
//  3. **Fallback**: dial the public fqdn directly. Used when we can't
//     figure out an internal dial target (e.g. admin URL is a unix
//     socket, or the server row is unreadable).
func (s *Server) tlsHandshakeOK(serverID int64, fqdn string, proxied bool, resolvedIPs []string) bool {
	target := fqdn + ":443"
	switch {
	case proxied && len(resolvedIPs) > 0:
		target = resolvedIPs[0] + ":443"
	default:
		if host := s.caddyDialHost(serverID); host != "" {
			target = host + ":443"
		}
	}
	d := &net.Dialer{Timeout: 6 * time.Second}
	conn, err := tls.DialWithDialer(d, "tcp", target, &tls.Config{
		ServerName: fqdn,
	})
	if err != nil {
		return false
	}
	_ = conn.Close()
	return true
}

// caddyDialHost returns the hostname (no port) to use when dialing the
// Caddy server for serverID. Pulled from the admin URL so single-host
// setups resolve to "caddy" (the docker service name) and remote-server
// setups resolve to whatever address admin is configured on. Falls back
// to the primary client's admin URL when serverID isn't usable. Returns
// "" when nothing sensible can be extracted — callers then dial the
// public fqdn directly as a last resort.
func (s *Server) caddyDialHost(serverID int64) string {
	adminURL := ""
	if serverID > 0 {
		if srv, err := models.GetCaddyServer(s.DB, serverID); err == nil {
			adminURL = srv.AdminURL
		}
	}
	if adminURL == "" && s.Caddy != nil {
		adminURL = s.Caddy.AdminURL
	}
	if adminURL == "" {
		return ""
	}
	u, err := url.Parse(adminURL)
	if err != nil || u.Hostname() == "" {
		return ""
	}
	// Unix-socket admins ("http://unix") give an empty hostname above;
	// belt-and-braces skip them too since we can't dial :443 via a
	// socket path.
	if strings.EqualFold(u.Scheme, "unix") || strings.EqualFold(u.Host, "unix") {
		return ""
	}
	return u.Hostname()
}

// proxyHostDeploying renders the post-save "deploying" checklist page.
// Shown after a create/update that created or changed a managed-DNS
// record. The page JS polls /api/proxy-hosts/{id}/deploy-status every
// few seconds and auto-redirects to /proxy-hosts once both DNS and
// cert checks pass — or after the hard 120s timeout.
func (s *Server) proxyHostDeploying(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	host, err := models.GetProxyHost(s.DB, id)
	if err != nil || host == nil {
		http.NotFound(w, r)
		return
	}
	cu := s.currentUser(r)
	if cu != nil && cu.Role != models.RoleAdmin {
		if !s.canManageOwned(cu, host.OwnerID) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
	}
	providerName := host.DNSProvider
	if d, ok := dns.Lookup(host.DNSProvider); ok {
		providerName = d.DisplayName
	}
	s.render(w, r, "proxy_host_deploying.html", map[string]any{
		"User":         cu,
		"Host":         host,
		"FirstDomain":  dns.FirstDomain(host.Domains),
		"ProviderName": providerName,
		"ExpectedIP":   s.serverIPFor(host.ServerID),
		"Section":      "proxy",
	})
}

// firstRawRouteHost pulls the first hostname out of a raw-route JSON
// blob's match.host[] array. v2.5.5 uses this to drive the post-save
// deploying page for advanced routes — if the route has no host matcher
// (path-only, port-only, etc.) we return "" and the caller skips the
// deploying page entirely since there's nothing DNS- or TLS-shaped to
// probe.
//
// Parsing is defensive: any shape mismatch in the JSON returns empty
// rather than panicking, because raw routes intentionally accept arbitrary
// Caddy JSON and we don't want a malformed blob to break the post-save
// redirect. The save path already runs the config through Caddy's adapter
// before we get here, so well-formed routes reach this function with the
// canonical match[].host[] shape.
func firstRawRouteHost(jsonData string) string {
	if strings.TrimSpace(jsonData) == "" {
		return ""
	}
	var cfg map[string]any
	if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
		return ""
	}
	matches, _ := cfg["match"].([]any)
	for _, m := range matches {
		mm, _ := m.(map[string]any)
		hosts, _ := mm["host"].([]any)
		for _, h := range hosts {
			if s, ok := h.(string); ok && strings.TrimSpace(s) != "" {
				return strings.TrimSpace(s)
			}
		}
	}
	return ""
}

// apiRawRouteDeployStatus mirrors apiProxyHostDeployStatus for advanced
// routes. Raw routes don't manage their own DNS records (the user wires
// A records manually), so the "record created in <provider>" step is
// absent — but DNS propagation + TLS handshake still apply and are
// exactly the signal the user wants after saving.
func (s *Server) apiRawRouteDeployStatus(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if err != nil {
		_ = json.NewEncoder(w).Encode(map[string]any{"error": "bad id"})
		return
	}
	rr, err := models.GetRawRoute(s.DB, id)
	if err != nil || rr == nil {
		w.WriteHeader(http.StatusNotFound)
		_ = json.NewEncoder(w).Encode(map[string]any{"error": "not found"})
		return
	}
	cu := s.currentUser(r)
	if cu != nil && cu.Role != models.RoleAdmin {
		if !s.canManageOwned(cu, rr.OwnerID) {
			w.WriteHeader(http.StatusForbidden)
			_ = json.NewEncoder(w).Encode(map[string]any{"error": "forbidden"})
			return
		}
	}
	fqdn := firstRawRouteHost(rr.JSONData)
	serverID := s.currentServerID(r)
	expectedIP := s.serverIPFor(serverID)
	// Raw routes are always HTTPS-capable in Caddy (automatic_https is
	// on by default) unless the user manually carved it out — we treat
	// SSL as always-on for the cert check. A custom cert (CertificateID > 0)
	// skips ACME but still terminates TLS, so the probe applies either way.
	resp := map[string]any{
		"fqdn":         fqdn,
		"expected_ip":  expectedIP,
		"ssl_enabled":  true,
		"proxied":      false,
		"dns_ready":    false,
		"cert_ready":   false,
		"resolved_ips": []string{},
		"dns_skipped":  rr.DNSProvider != "" && rr.DNSSkipRecord,
	}
	if rr.DNSProvider != "" && rr.DNSSkipRecord {
		resp["dns_ready"] = true
	}
	if fqdn == "" {
		resp["error"] = "route has no host matcher"
		_ = json.NewEncoder(w).Encode(resp)
		return
	}
	// Raw routes don't carry a DNS provider, but the Cloudflare-proxied
	// toggle is a server-wide setting — if the user has CF proxy on and
	// the domain happens to sit in a CF zone, their A record resolves to
	// CF edge IPs even though the raw route doesn't know about it. Trust
	// the resolved IP shape rather than the setting: if DoH returns a CF
	// edge IP (known-proxied ranges), treat it as proxied.
	var ips []string
	if !rr.DNSSkipRecord {
		if got, dnsErr := s.resolveVerifyA(fqdn); dnsErr == nil {
			ips = got
			resp["resolved_ips"] = got
			if looksLikeCloudflareEdge(got) {
				resp["proxied"] = true
				resp["dns_ready"] = len(got) > 0
			} else if expectedIP != "" {
				for _, ip := range got {
					if ip == expectedIP {
						resp["dns_ready"] = true
						break
					}
				}
			} else {
				resp["dns_ready"] = len(got) > 0
			}
		}
	}
	if resp["dns_ready"] == true {
		proxied, _ := resp["proxied"].(bool)
		resp["cert_ready"] = s.tlsHandshakeOK(serverID, fqdn, proxied, ips)
	}
	_ = json.NewEncoder(w).Encode(resp)
}

// rawRouteDeploying renders the deploying-checklist page for advanced
// routes. If the route has no host matcher we fall through to the list —
// there's nothing meaningful to poll on a port-only / path-only route.
func (s *Server) rawRouteDeploying(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	rr, err := models.GetRawRoute(s.DB, id)
	if err != nil || rr == nil {
		http.NotFound(w, r)
		return
	}
	cu := s.currentUser(r)
	if cu != nil && cu.Role != models.RoleAdmin {
		if !s.canManageOwned(cu, rr.OwnerID) {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
	}
	fqdn := firstRawRouteHost(rr.JSONData)
	// Issue #121: no host to probe, or a plain-HTTP route (explicit http://
	// address) — there is no HTTPS certificate to wait for, so skip the page.
	if fqdn == "" || rawRouteHTTPOnly(rr) {
		http.Redirect(w, r, "/raw-routes", http.StatusSeeOther)
		return
	}
	// v2.5.6: if Managed DNS is active on this route, render the "DNS
	// record created in <provider>" step by passing the display name.
	providerName := rr.DNSProvider
	if d, ok := dns.Lookup(rr.DNSProvider); ok {
		providerName = d.DisplayName
	}
	serverID := s.currentServerID(r)
	s.render(w, r, "raw_route_deploying.html", map[string]any{
		"User":         cu,
		"Route":        rr,
		"FirstDomain":  fqdn,
		"ProviderName": providerName,
		"ExpectedIP":   s.serverIPFor(serverID),
		"Section":      "raw",
	})
}

// looksLikeCloudflareEdge is a coarse heuristic to spot CF-proxied A
// records from their resolved IP. We use it on raw routes where the
// explicit "proxied" setting is on proxy hosts only. The ranges below
// are Cloudflare's published IPv4 edge set as of 2024 — keeping this
// small + local (vs. fetching cloudflare.com/ips-v4) avoids a network
// dependency on the deploy-status poll. False positives here just mean
// we dial CF edge with SNI=fqdn instead of Caddy internally, which is
// harmless when the domain really is on CF and correctly fails to
// verify when it isn't.
func looksLikeCloudflareEdge(ips []string) bool {
	// Canonical CF v4 edge CIDRs (from https://www.cloudflare.com/ips-v4).
	cfRanges := []string{
		"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
		"103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
		"190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
		"198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
		"104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
	}
	for _, ip := range ips {
		parsed := net.ParseIP(ip)
		if parsed == nil {
			continue
		}
		for _, cidr := range cfRanges {
			_, n, err := net.ParseCIDR(cidr)
			if err != nil {
				continue
			}
			if n.Contains(parsed) {
				return true
			}
		}
	}
	return false
}

// semverValid returns true for tags like v1.2.3.
func semverValid(v string) bool {
	if len(v) < 6 || v[0] != 'v' {
		return false
	}
	parts := strings.SplitN(v[1:], ".", 3)
	if len(parts) != 3 {
		return false
	}
	for _, p := range parts {
		if len(p) == 0 {
			return false
		}
		for _, c := range p {
			if c < '0' || c > '9' {
				return false
			}
		}
	}
	return true
}

// semverGT returns true when a > b (both must be valid semver like v1.2.3).
func semverGT(a, b string) bool {
	pa := semverParts(a)
	pb := semverParts(b)
	for i := 0; i < 3; i++ {
		if pa[i] > pb[i] {
			return true
		}
		if pa[i] < pb[i] {
			return false
		}
	}
	return false
}

func semverParts(v string) [3]int {
	var out [3]int
	if len(v) > 0 && v[0] == 'v' {
		v = v[1:]
	}
	parts := strings.SplitN(v, ".", 3)
	for i := 0; i < 3 && i < len(parts); i++ {
		out[i], _ = strconv.Atoi(parts[i])
	}
	return out
}

func (s *Server) getBackup(w http.ResponseWriter, r *http.Request) {
	if appdb.BackendOf(s.DB) == appdb.BackendMariaDB {
		http.Error(w, "MariaDB backups are managed by your database server. Use mariadb-dump or your platform's scheduled backup tooling.", http.StatusNotImplemented)
		return
	}
	// Write the VACUUM INTO temp file next to the live DB rather than /tmp.
	// Rationale: the scratch-based final image has no /tmp directory and the
	// process runs as a non-root UID, so `os.TempDir()` returns "/tmp" but
	// SQLite's sqlite3_open_v2 gets CANTOPEN (errcode 14). The directory
	// containing the DB is guaranteed to exist (we just opened the DB from
	// there) and is writable by our UID (we write WAL + SHM there every
	// transaction). Falls back to os.TempDir() only if DBPath wasn't plumbed
	// through — every call site in main.go does plumb it, so the fallback is
	// defence-in-depth against a future constructor regression. v2.7.5.
	ts := time.Now().Format("20060102-150405")
	backupDir := os.TempDir()
	if s.DBPath != "" {
		backupDir = filepath.Dir(s.DBPath)
	}
	tmpPath := filepath.Join(backupDir, fmt.Sprintf("caddyui-backup-%s.db", ts))
	defer os.Remove(tmpPath)

	if _, err := s.DB.Exec("VACUUM INTO ?", tmpPath); err != nil {
		http.Error(w, "backup failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	f, err := os.Open(tmpPath)
	if err != nil {
		http.Error(w, "open backup: "+err.Error(), http.StatusInternalServerError)
		return
	}
	defer f.Close()

	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Disposition", `attachment; filename="caddyui-backup-`+ts+`.db"`)
	io.Copy(w, f)
}

func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
	// v2.44.0: one page per area; /settings is the first page.
	settingsPage := defaultSettingsSection
	if raw := chi.URLParam(r, "section"); raw != "" {
		if settingsPage = settingsSectionSlug(raw); settingsPage == "" {
			http.NotFound(w, r)
			return
		}
	}
	lang := s.requestLocale(r, s.currentUser(r)) // v2.63.0: section names in the reader's language
	webhookURL, _ := models.GetSetting(s.DB, settingNotifyWebhookURL)
	// v2.12.51: ntfy.sh push channel — load alongside the existing webhook.
	ntfyURL, _ := models.GetSetting(s.DB, settingNotifyNtfyURL)
	ntfyToken, _ := models.GetSetting(s.DB, settingNotifyNtfyToken)
	daysBeforeStr, _ := models.GetSetting(s.DB, settingNotifyDaysBefore)
	daysBefore := defaultNotifyDaysBefore
	if d, err := strconv.Atoi(daysBeforeStr); err == nil && d > 0 {
		daysBefore = d
	}

	// v2.11.15: AI assistant settings. v2.12.36: multi-provider — load
	// every provider's credentials so the form can swap visible fields
	// without losing the inactive providers' values on save.
	aiEnabledStr, _ := models.GetSetting(s.DB, settingAIEnabled)
	aiProvider, _ := models.GetSetting(s.DB, settingAIProvider)
	if aiProvider == "" {
		aiProvider = "ollama"
	}
	aiOllamaURL, _ := models.GetSetting(s.DB, settingAIOllamaURL)
	if aiOllamaURL == "" {
		aiOllamaURL = "http://ollama:11434"
	}
	aiOllamaModel, _ := models.GetSetting(s.DB, settingAIOllamaModel)
	if aiOllamaModel == "" {
		aiOllamaModel = "llama3.2:latest"
	}
	aiOllamaCloudKey, _ := models.GetSetting(s.DB, settingAIOllamaCloudAPIKey)
	aiOllamaCloudModel, _ := models.GetSetting(s.DB, settingAIOllamaCloudModel)
	if aiOllamaCloudModel == "" {
		aiOllamaCloudModel = "qwen3-coder:480b-cloud"
	}
	aiAnthropicKey, _ := models.GetSetting(s.DB, settingAIAnthropicAPIKey)
	aiAnthropicModel, _ := models.GetSetting(s.DB, settingAIAnthropicModel)
	if aiAnthropicModel == "" {
		aiAnthropicModel = "claude-haiku-4-5-20251001"
	}
	aiOpenAIBase, _ := models.GetSetting(s.DB, settingAIOpenAIBaseURL)
	if aiOpenAIBase == "" {
		aiOpenAIBase = "https://api.openai.com/v1"
	}
	aiOpenAIKey, _ := models.GetSetting(s.DB, settingAIOpenAIAPIKey)
	aiOpenAIModel, _ := models.GetSetting(s.DB, settingAIOpenAIModel)
	if aiOpenAIModel == "" {
		aiOpenAIModel = "gpt-4o-mini"
	}
	aiSystemPrompt, _ := models.GetSetting(s.DB, settingAISystemPrompt)
	aiRequestTimeout := strings.TrimSpace(mustGetSetting(s.DB, settingAIRequestTimeoutSec))
	if aiRequestTimeout == "" {
		aiRequestTimeout = strconv.Itoa(defaultAIRequestTimeoutSec)
	}
	globalStripHdrs, _ := models.GetSetting(s.DB, settingGlobalStripResponseHeaders)

	smtpHost, _ := models.GetSetting(s.DB, settingSMTPHost)
	smtpPort, _ := models.GetSetting(s.DB, settingSMTPPort)
	smtpUsername, _ := models.GetSetting(s.DB, settingSMTPUsername)
	smtpFrom, _ := models.GetSetting(s.DB, settingSMTPFrom)
	smtpTo, _ := models.GetSetting(s.DB, settingSMTPTo)
	smtpSecurity, _ := models.GetSetting(s.DB, settingSMTPSecurity)
	smtpSkipVerify, _ := models.GetSetting(s.DB, settingSMTPSkipVerify)
	if smtpPort == "" {
		smtpPort = "587"
	}
	if smtpSecurity == "" {
		smtpSecurity = "starttls"
	}
	smtpConfigured := smtpHost != "" && smtpTo != ""

	turnstileSiteKey, _ := models.GetSetting(s.DB, settingTurnstileSiteKey)
	turnstileSecretKey, _ := models.GetSetting(s.DB, settingTurnstileSecretKey)

	// v2.5.0: captcha provider selector + reCAPTCHA keys alongside the
	// existing Turnstile keys. The UI renders a radio (Off / Turnstile /
	// reCAPTCHA); only the active provider's key fields are interactable.
	captchaProvider := normalizeCaptchaProvider(mustGetSetting(s.DB, settingCaptchaProvider))
	recaptchaSiteKey := mustGetSetting(s.DB, settingRecaptchaSiteKey)
	recaptchaSecretKey := mustGetSetting(s.DB, settingRecaptchaSecretKey)
	recaptchaMinScoreRaw := mustGetSetting(s.DB, settingRecaptchaMinScore)
	if strings.TrimSpace(recaptchaMinScoreRaw) == "" {
		recaptchaMinScoreRaw = fmt.Sprintf("%.1f", captchaDefaultMinScore)
	}

	// Timezone — admin-picked IANA zone for rendering timestamps. Empty =
	// fall back to TZ env var then UTC. See timezone.go for the priority
	// order and the dropdown options (commonTimezones).
	timezoneSaved, _ := models.GetSetting(s.DB, settingTimezone)

	serverIP := s.serverIP()
	cfProxiedStr, _ := models.GetSetting(s.DB, settingCFProxied)

	// v2.4.0: per-server public IPs. Load every Caddy server so the DNS
	// card can render one IP input per server. At least one IP is required
	// for managed DNS to work — either a server row has public_ip set or
	// the legacy global fallback is populated.
	caddyServers, _ := models.ListCaddyServers(s.DB)
	accessLogCfg := loadFleetAccessLogConfig(s.DB)
	crowdSecCfg := loadCrowdSecConfig(s.DB)
	metricsCfg := loadPrometheusMetricsConfig(s.DB)
	metricsScrapeTargets := make(map[int64]string, len(caddyServers))
	for _, sr := range caddyServers {
		metricsScrapeTargets[sr.ID] = prometheusScrapeTarget(sr.AdminURL)
	}
	hasAnyServerIP := strings.TrimSpace(serverIP) != ""
	for _, sr := range caddyServers {
		if strings.TrimSpace(sr.PublicIP) != "" {
			hasAnyServerIP = true
			break
		}
	}

	// Build a view-model row per registered DNS provider so the template
	// can render the cards in a simple range loop. Each card gets:
	//   ID, DisplayName, DocsAnchor, Credentials (with "Configured" bool),
	//   Configured (bool summing all credential fields).
	type credView struct {
		dns.CredentialField
		Configured bool
		// Value is the stored plaintext. Only rendered for non-secret fields
		// (Namecheap's API user + whitelisted IP) — secret fields are never
		// echoed back to the page.
		Value string
	}
	type providerView struct {
		ID          string
		DisplayName string
		DocsAnchor  string
		Credentials []credView
		Configured  bool           // every field non-empty
		Enabled     bool           // Configured AND serverIP set
		ExtraFlags  map[string]any // per-provider extras (e.g. Cloudflare proxied toggle)
		// v2.4.7: zone allow-list. ZoneAllowlistRaw is the textarea value
		// (one domain per line for readability); ZoneAllowlist is the parsed
		// slice used to render the "N of M zones visible" hint.
		ZoneAllowlistRaw string
		ZoneAllowlist    []string
	}
	var providers []providerView
	for _, d := range dns.Descriptors() {
		pv := providerView{
			ID:          d.ID,
			DisplayName: d.DisplayName,
			DocsAnchor:  d.DocsAnchor,
			ExtraFlags:  map[string]any{},
		}
		allFilled := true
		for _, c := range d.Credentials {
			v, _ := models.GetSetting(s.DB, c.Key)
			set := v != ""
			if !set && !c.Optional {
				allFilled = false
			}
			cv := credView{CredentialField: c, Configured: set}
			// Only expose stored value back to the page for non-secret fields.
			// Secrets stay keep-blank-to-preserve so they're never rendered
			// even in a password input's value attribute.
			if !c.Secret {
				cv.Value = v
			}
			pv.Credentials = append(pv.Credentials, cv)
		}
		pv.Configured = allFilled
		pv.Enabled = allFilled && hasAnyServerIP
		if d.ID == dns.Cloudflare {
			pv.ExtraFlags["Proxied"] = cfProxiedStr == "1"
		}
		// v2.4.7: load the zone allow-list for this provider so the
		// textarea renders with the current value. Render one domain per
		// line (nicer than CSV for hand-editing).
		pv.ZoneAllowlist = s.zoneAllowlist(d.ID)
		if len(pv.ZoneAllowlist) > 0 {
			pv.ZoneAllowlistRaw = strings.Join(pv.ZoneAllowlist, "\n")
		}
		providers = append(providers, pv)
	}

	// v2.7.0: visitor analytics toggle + IP-exclusion list. Loaded via a
	// helper in analytics.go so both the settings page and the POST handler
	// see the same field names.
	analyticsCfg := loadAnalyticsConfig(s.DB)
	var analyticsIngestSnap map[string]any
	if s.analyticsIngest != nil {
		snap := s.analyticsIngest.Stats()
		analyticsIngestSnap = map[string]any{
			"Connections": snap.Connections,
			"Events":      snap.Events,
			"Excluded":    snap.Excluded,
			"Errors":      snap.Errors,
			"LastEvent":   snap.LastEventAt,
			"Healthy":     snap.Events > 0 && time.Since(snap.LastEventAt) < 10*time.Minute,
		}
	}

	success := r.URL.Query().Get("saved") == "1"
	// "cleared=<provider-id>" is set by postClearDNSProvider's redirect.
	// Resolve it back to the pretty display name so the banner can say
	// "Cloudflare credentials cleared" instead of "cloudflare credentials
	// cleared". Unknown IDs are ignored silently.
	var clearedName string
	if cid := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("cleared"))); cid != "" {
		for _, pv := range providers {
			if pv.ID == cid {
				clearedName = pv.DisplayName
				break
			}
		}
	}
	s.render(w, r, "settings.html", map[string]any{
		"User":          s.currentUser(r),
		"WebhookURL":    webhookURL,
		"WebhookSecret": mustGetSetting(s.DB, settingNotifyWebhookSecret),
		// v2.12.51: ntfy.sh — token uses *Set boolean (not the value) so
		// it never re-renders into the form, same pattern as the v2.12.37
		// API-key fields. URL is fine to render — it's not secret.
		"NtfyURL":             ntfyURL,
		"NtfyTokenSet":        strings.TrimSpace(ntfyToken) != "",
		"DaysBefore":          daysBefore,
		"AIEnabled":           aiEnabledStr == "1",
		"AIProvider":          aiProvider,
		"AIOllamaURL":         aiOllamaURL,
		"AIOllamaModel":       aiOllamaModel,
		"AIOllamaCloudKeySet": strings.TrimSpace(aiOllamaCloudKey) != "",
		"AIOllamaCloudModel":  aiOllamaCloudModel,
		"AIAnthropicKeySet":   strings.TrimSpace(aiAnthropicKey) != "",
		"AIAnthropicModel":    aiAnthropicModel,
		"AIOpenAIBaseURL":     aiOpenAIBase,
		"AIOpenAIKeySet":      strings.TrimSpace(aiOpenAIKey) != "",
		"AIOpenAIModel":       aiOpenAIModel,
		"AISystemPrompt":      aiSystemPrompt,
		"AIRequestTimeout":    aiRequestTimeout,
		"GlobalStripHeaders":  globalStripHdrs,
		"SMTPHost":            smtpHost,
		"SMTPPort":            smtpPort,
		"SMTPUsername":        smtpUsername,
		"SMTPFrom":            smtpFrom,
		"SMTPTo":              smtpTo,
		"SMTPSecurity":        smtpSecurity,
		"SMTPSkipVerify":      smtpSkipVerify == "1",
		"SMTPConfigured":      smtpConfigured,
		"TurnstileSiteKey":    turnstileSiteKey,
		// v2.27.0: secret keys pass as *Set booleans, never as values. Rendering
		// them into <input value="..."> leaked the plaintext through F12 →
		// Elements despite type="password" — same bug fixed for the AI provider
		// keys in v2.12.37 and flagged as outstanding for captcha back then.
		"TurnstileSecretKeySet": strings.TrimSpace(turnstileSecretKey) != "",
		"TurnstileEnabled":      turnstileSiteKey != "" && turnstileSecretKey != "",
		// v2.5.0: captcha provider + reCAPTCHA keys
		"CaptchaProvider":       captchaProvider,
		"CaptchaDisabledByEnv":  captchaDisabledByEnv(),
		"RecaptchaSiteKey":      recaptchaSiteKey,
		"RecaptchaSecretKeySet": strings.TrimSpace(recaptchaSecretKey) != "",
		"RecaptchaMinScore":     recaptchaMinScoreRaw,
		"RecaptchaEnabled":      recaptchaSiteKey != "" && recaptchaSecretKey != "",
		// Timezone: Timezone is the saved DB value (may be ""). TimezoneActive
		// is what the server is *actually* rendering in right now — useful as
		// a "(currently: UTC)" hint when the DB value is empty.
		"Timezone":              timezoneSaved,
		"TimezoneActive":        activeLocation().String(),
		"TimezoneOptions":       commonTimezones,
		"ServerIP":              serverIP,
		"Servers":               caddyServers,
		"DNSProviders":          providers,
		"DNSCredentialProfiles": s.dnsProfileViews(),
		// Back-compat aliases for any embed that still references the old
		// CF-centric keys. The template itself now uses DNSProviders +
		// ServerIP; these stay so custom layouts built against v2.2.x
		// don't crash during the upgrade cycle.
		"CFServerIP": serverIP,
		"CFProxied":  cfProxiedStr == "1",
		// issue #98: verification resolver override for deploy/readiness checks.
		"DNSVerifyResolver": mustGetSetting(s.DB, settingDNSVerifyResolver),
		// issue #116: resolver override for Caddy's own ACME DNS-01 challenge.
		"ACMEDNSResolvers": mustGetSetting(s.DB, settingACMEDNSResolvers),
		// issue #100: fleet-wide IP blocklist.
		"GlobalIPBlocklist": mustGetSetting(s.DB, settingGlobalIPBlocklist),
		// issue #104: scheduled backups.
		"BackupScheduleEnabled":  mustGetSetting(s.DB, settingBackupScheduleEnabled) == "1",
		"BackupScheduleDir":      mustGetSetting(s.DB, settingBackupScheduleDir),
		"BackupScheduleInterval": mustGetSetting(s.DB, settingBackupScheduleInterval),
		"BackupScheduleKeep":     mustGetSetting(s.DB, settingBackupScheduleKeep),
		"BackupOK":               r.URL.Query().Get("backupok"),
		"BackupErr":              r.URL.Query().Get("backuperr"),
		// issue #106: OIDC / SSO. The client secret is never rendered back — a
		// bool tells the template whether one is already stored.
		"OIDCEnabled":         mustGetSetting(s.DB, settingOIDCEnabled) == "1",
		"OIDCIssuer":          mustGetSetting(s.DB, settingOIDCIssuer),
		"OIDCClientID":        mustGetSetting(s.DB, settingOIDCClientID),
		"OIDCClientSecretSet": strings.TrimSpace(mustGetSetting(s.DB, settingOIDCClientSecret)) != "",
		"OIDCRedirectURL":     mustGetSetting(s.DB, settingOIDCRedirectURL),
		"OIDCAutoCreate":      mustGetSetting(s.DB, settingOIDCAutoCreate) == "1",
		"OIDCButtonLabel":     mustGetSetting(s.DB, settingOIDCButtonLabel),
		// v2.65.0 (issue #139): role assignment for auto-created SSO users,
		// plus the exact callback URL spelled out where it cannot be clipped
		// by the width of the input it belongs to.
		"OIDCDefaultRole":          mustGetSetting(s.DB, settingOIDCDefaultRole),
		"OIDCGroupsClaim":          mustGetSetting(s.DB, settingOIDCGroupsClaim),
		"OIDCRoleMappings":         mustGetSetting(s.DB, settingOIDCRoleMappings),
		"OIDCAllowedDomains":       mustGetSetting(s.DB, settingOIDCAllowedDomains), // v2.67.0
		"OIDCSuggestedRedirectURL": s.publicBaseURL(r) + "/auth/oidc/callback",
		"Success":                  success,
		"ClearedName":              clearedName,
		// v2.7.0: analytics card
		"AnalyticsEnabled":           analyticsCfg.Enabled,
		"ExpectationsAutoRollback":   expectationsAutoRollbackEnabled(s), // v2.38.0
		"AnalyticsTarget":            analyticsCfg.TargetRaw,
		"AnalyticsTargetPlaceholder": defaultAnalyticsIngestTarget,
		"AnalyticsExcludeRaw":        analyticsCfg.ExcludeRaw,
		"AnalyticsSoftStart":         analyticsCfg.SoftStart,
		"AnalyticsDialTimeoutSec":    int(analyticsCfg.DialTimeout / time.Second),
		"AnalyticsIngestStats":       analyticsIngestSnap,
		"AnalyticsRetentionDays":     analyticsRetentionDays(s), // v2.43.0
		"AnalyticsStorage":           s.analyticsStorageView(),  // v2.43.0
		// Fleet access logging and CrowdSec integrations (v2.21.0).
		"AccessLogEnabled":        accessLogCfg.Enabled,
		"AccessLogPath":           accessLogCfg.Path,
		"AccessLogFormat":         accessLogCfg.Format,
		"AccessLogScope":          accessLogCfg.Scope,
		"AccessLogRollSize":       accessLogCfg.RollSizeMB,
		"AccessLogRollKeep":       accessLogCfg.RollKeep,
		"AccessLogRollDays":       accessLogCfg.RollKeepDays,
		"AccessLogServerSelected": integrationServerSelection(caddyServers, accessLogCfg.ServerIDs),
		"ClientIPHeaders":         mustGetSetting(s.DB, settingClientIPHeaders),
		"CrowdSecEnabled":         crowdSecCfg.Enabled,
		"CrowdSecAPIURL":          crowdSecCfg.APIURL,
		"CrowdSecAPIKeySet":       strings.TrimSpace(crowdSecCfg.APIKey) != "",
		"CrowdSecStreaming":       crowdSecCfg.Streaming,
		"CrowdSecTicker":          crowdSecCfg.Ticker,
		"CrowdSecHardFails":       crowdSecCfg.HardFails,
		"CrowdSecServerSelected":  integrationServerSelection(caddyServers, crowdSecCfg.ServerIDs),
		"CrowdSecExcludedHosts":   mustGetSetting(s.DB, settingCrowdSecExcludeHost),
		"CrowdSecExcludedPaths":   mustGetSetting(s.DB, settingCrowdSecExcludePath),
		"MetricsEnabled":          metricsCfg.Enabled,
		"MetricsPerHost":          metricsCfg.PerHost,
		"MetricsObserveCatchAll":  metricsCfg.ObserveCatchAllHost,
		"MetricsServerSelected":   integrationServerSelection(caddyServers, metricsCfg.ServerIDs),
		"MetricsScrapeTargets":    metricsScrapeTargets,
		// v2.64.1 (issue #138): one two-factor policy toggle. Reflects either
		// key so an install that only ever set the deprecated one still shows
		// the switch as on.
		"RequireTOTP": func() string {
			if s.twoFactorRequired() {
				return "1"
			}
			return "0"
		}(),
		// v2.10.0: trusted proxies + custom site title
		"TrustedProxies": mustGetSetting(s.DB, settingTrustedProxies),
		"SiteTitle":      mustGetSetting(s.DB, settingSiteTitle),
		// v2.53.0 (review finding #8): extra directories CaddyUI may read
		// certificate files from, plus the full effective list so the page
		// shows what is already allowed without being configured.
		"CertificateReadRoots":          mustGetSetting(s.DB, settingCertificateReadRoots),
		"CertificateReadRootsEffective": strings.Join(s.certificateReadRoots(), ", "),
		// v2.11.0: custom favicon + admin IP allowlist
		"FaviconURL":     mustGetSetting(s.DB, settingFaviconURL),
		"AdminAllowlist": mustGetSetting(s.DB, settingAdminAllowlist),
		// v2.65.0 (issue #140): the address this request arrived with, so the
		// operator can see what the allowlist will actually be matching
		// before they save a list that might exclude them.
		"CurrentClientIP": func() string {
			ip, _ := s.resolvedClientIP(r)
			return ip
		}(),
		"CurrentClientIPViaProxy": func() bool {
			_, viaProxy := s.resolvedClientIP(r)
			return viaProxy
		}(),
		"AdminAllowlistFromEnv": s.adminAllowlistFromEnv(),
		// v2.12.0: configurable session duration + global catch-all 404
		"SessionDays":          mustGetSetting(s.DB, settingSessionDays),
		"CatchAll404HTML":      mustGetSetting(s.DB, settingCatchAll404HTML),
		"FallbackStatus":       s.fallbackStatusCode(),                     // v2.60.0 (issue #123)
		"DefaultLocale":        mustGetSetting(s.DB, settingDefaultLocale), // v2.62.0 (issue #128)
		"Locales":              translations().Locales(),
		"GlobalMaintenance":    mustGetSetting(s.DB, settingGlobalMaintenance),
		"AutoSyncHours":        mustGetSetting(s.DB, settingAutoSyncHours),
		"ActivityLogDays":      mustGetSetting(s.DB, settingActivityLogDays),
		"MaxLoginAttempts":     mustGetSetting(s.DB, settingMaxLoginAttempts),
		"DisableHTTP3":         mustGetSetting(s.DB, settingDisableHTTP3),
		"DatabaseBackend":      string(appdb.BackendOf(s.DB)),
		"Section":              "settings",
		"SettingsSection":      settingsPage,                                      // v2.44.0
		"SettingsSectionLabel": localizedSettingsSectionLabel(lang, settingsPage), // v2.44.0; v2.63.0 translated
		"SettingsNav":          localizedSettingsSections(lang),                   // v2.44.0; v2.63.0 translated
		"SettingsAnchorsJSON":  settingsAnchorsJSON(),                             // v2.44.0
	})
}

// requireTOTPFormValue reads the single two-factor policy toggle from the
// settings form. The two toggles it replaces used different checkbox
// conventions — one value="1", one bare "on" — so both are accepted rather
// than letting a stale form silently save "off". v2.64.1 (issue #138).
func requireTOTPFormValue(r *http.Request) string {
	switch r.FormValue("require_totp") {
	case "1", "on":
		return "1"
	}
	return "0"
}

func (s *Server) postSettings(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	// v2.44.0: the posted page owns a subset of keys; "" is the legacy
	// whole-form post (API clients, old bookmarks) and saves everything.
	settingsPage := settingsSectionSlug(r.FormValue("settings_section"))
	// v2.65.0 (issue #139): a group rule naming a role CaddyUI does not have
	// is ignored at sign-in time, which would mean saving a mapping that
	// quietly never applies. Say so instead.
	if bad := invalidOIDCRoleMappingLines(r.FormValue("oidc_role_mappings")); len(bad) > 0 {
		http.Error(w, "Group → role rules: "+strings.Join(bad, "; ")+
			". Use one `group|role` per line, with role being admin, user or view.", http.StatusBadRequest)
		return
	}
	integrationSettingsPresent := r.FormValue("fleet_integrations_present") == "1"
	accessLogFormCfg := loadFleetAccessLogConfig(s.DB)
	crowdSecFormCfg := loadCrowdSecConfig(s.DB)
	metricsFormCfg := loadPrometheusMetricsConfig(s.DB)
	if integrationSettingsPresent {
		var err error
		accessLogFormCfg, err = fleetAccessLogConfigFromForm(r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		previousCrowdSec := crowdSecFormCfg
		crowdSecFormCfg, err = crowdSecConfigFromForm(r, s.DB)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if crowdSecFormCfg.Enabled && (crowdSecConfigFingerprint(previousCrowdSec) != crowdSecConfigFingerprint(crowdSecFormCfg) || strings.TrimSpace(r.FormValue("crowdsec_api_key")) != "") {
			if err := s.validateCrowdSecServers(crowdSecFormCfg); err != nil {
				http.Error(w, "CrowdSec validation failed: "+err.Error(), http.StatusBadRequest)
				return
			}
		}
		previousMetrics := metricsFormCfg
		metricsFormCfg, err = prometheusMetricsConfigFromForm(r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if metricsFormCfg.Enabled && prometheusMetricsConfigFingerprint(previousMetrics) != prometheusMetricsConfigFingerprint(metricsFormCfg) {
			if err := s.validatePrometheusMetricsServers(metricsFormCfg); err != nil {
				http.Error(w, "Prometheus metrics validation failed: "+err.Error(), http.StatusBadRequest)
				return
			}
		}
	}
	webhookURL := strings.TrimSpace(r.FormValue("webhook_url"))
	webhookSecret := strings.TrimSpace(r.FormValue("webhook_secret"))
	// v2.12.51: ntfy.sh URL stored verbatim; token uses keep-blank-to-preserve
	// (handled below alongside SMTP password to avoid the F12 leak fix from v2.12.37).
	ntfyURL := strings.TrimSpace(r.FormValue("notify_ntfy_url"))
	daysBeforeStr := strings.TrimSpace(r.FormValue("days_before"))
	daysBefore := defaultNotifyDaysBefore
	if d, err := strconv.Atoi(daysBeforeStr); err == nil && d > 0 {
		daysBefore = d
	}

	smtpHost := strings.TrimSpace(r.FormValue("smtp_host"))
	smtpPort := strings.TrimSpace(r.FormValue("smtp_port"))
	smtpUsername := strings.TrimSpace(r.FormValue("smtp_username"))
	smtpPassword := r.FormValue("smtp_password") // keep as-is (may contain spaces)
	smtpFrom := strings.TrimSpace(r.FormValue("smtp_from"))
	smtpTo := strings.TrimSpace(r.FormValue("smtp_to"))
	smtpSecurity := strings.TrimSpace(r.FormValue("smtp_security"))
	// Same hidden+checkbox pattern as analytics_enabled below — scan the
	// full PostForm slice for "1" because FormValue always returns the
	// hidden input's "0" when both are submitted. See note on
	// analytics_enabled for the full story. This toggle had the bug from
	// day one; v2.7.1 fixes it here too so anyone who actually needs to
	// disable TLS cert verification (self-signed SMTP host) can finally
	// persist the setting.
	smtpSkipVerify := "0"
	for _, v := range r.PostForm["smtp_skip_verify"] {
		if v == "1" {
			smtpSkipVerify = "1"
			break
		}
	}
	if smtpPort == "" {
		smtpPort = "587"
	}
	if smtpSecurity == "" {
		smtpSecurity = "starttls"
	}

	turnstileSiteKey := strings.TrimSpace(r.FormValue("turnstile_site_key"))
	// turnstile_secret_key / recaptcha_secret_key are read further down, where
	// they are only persisted when non-empty (v2.27.0 keep-blank-to-preserve).

	// v2.5.0: captcha provider + reCAPTCHA keys. The provider radio
	// submits "off" / "turnstile" / "recaptcha"; normalizeCaptchaProvider
	// coerces anything else to "off" so a tampered POST can't set an
	// unknown mode that would 500 loadCaptchaConfig.
	captchaProvider := normalizeCaptchaProvider(r.FormValue("captcha_provider"))
	recaptchaSiteKey := strings.TrimSpace(r.FormValue("recaptcha_site_key"))
	// reCAPTCHA v3 threshold. Accept any 0.0–1.0 float; out-of-range or
	// unparseable values fall back to the default at load time. We still
	// store what the admin typed (after trim) so the next render of the
	// settings page shows their input, not the silent coercion.
	recaptchaMinScore := strings.TrimSpace(r.FormValue("recaptcha_min_score"))
	if recaptchaMinScore != "" {
		if _, err := strconv.ParseFloat(recaptchaMinScore, 64); err != nil {
			http.Error(w, "invalid recaptcha_min_score: "+err.Error(), http.StatusBadRequest)
			return
		}
	}

	// Timezone — the dropdown submits an IANA zone name (or empty string
	// for "use TZ env / UTC"). The "Other…" option in the UI falls back to
	// a free-text input that submits the same field. Validate via
	// time.LoadLocation; if bad, bail with 400 so we don't silently save
	// garbage the next boot can't decode.
	timezone := strings.TrimSpace(r.FormValue("timezone"))
	if timezone != "" {
		if _, err := time.LoadLocation(timezone); err != nil {
			http.Error(w, "invalid timezone: "+err.Error(), http.StatusBadRequest)
			return
		}
	}

	// v2.7.0: analytics toggle + ingest target + exclude-IPs. The target
	// may be blank — loadAnalyticsConfig substitutes the default at read
	// time, so we store the admin's literal input (possibly "").
	//
	// The hidden+checkbox pattern in the template submits
	// "analytics_enabled=0&analytics_enabled=1" when checked (hidden wins
	// submission order per HTML5 tree-order, checkbox is appended after).
	// r.FormValue returns the FIRST value, which is always the hidden "0",
	// so we have to scan the full r.PostForm slice to find the checkbox's
	// "1" when present. Matches the browser's "checked box overrides its
	// hidden sibling" intent. v2.7.1 fix — v2.7.0 used FormValue and the
	// toggle silently always stayed off.
	analyticsEnabled := "0"
	for _, v := range r.PostForm["analytics_enabled"] {
		if v == "1" {
			analyticsEnabled = "1"
			break
		}
	}
	analyticsTarget := strings.TrimSpace(r.FormValue("analytics_ingest_target"))
	// v2.38.0: post-apply checks — same hidden+checkbox pattern as above.
	expectationsAutoRollback := "0"
	for _, v := range r.PostForm["expectations_auto_rollback"] {
		if v == "1" {
			expectationsAutoRollback = "1"
			break
		}
	}
	analyticsExclude := r.FormValue("analytics_exclude_ips") // preserve whitespace for textarea re-render
	currentAnalyticsCfg := loadAnalyticsConfig(s.DB)
	analyticsSoftStart := "0"
	if currentAnalyticsCfg.SoftStart {
		analyticsSoftStart = "1"
	}
	if values, present := r.PostForm["analytics_soft_start"]; present {
		analyticsSoftStart = "0"
		for _, v := range values {
			if v == "1" {
				analyticsSoftStart = "1"
				break
			}
		}
	}
	// v2.43.0: retention days (0 = keep forever).
	analyticsRetention, err := parseAnalyticsRetentionDays(r.FormValue("analytics_retention_days"), analyticsRetentionDays(s))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	analyticsDialTimeoutSec := int(currentAnalyticsCfg.DialTimeout / time.Second)
	if raw, present := r.PostForm["analytics_dial_timeout_sec"]; present && len(raw) > 0 && strings.TrimSpace(raw[0]) != "" {
		n, err := strconv.Atoi(strings.TrimSpace(raw[0]))
		if err != nil || n < 1 || n > 60 {
			http.Error(w, "analytics connection timeout must be between 1 and 60 seconds", http.StatusBadRequest)
			return
		}
		analyticsDialTimeoutSec = n
	}

	// Shared server IP — the public IP every DNS provider writes as its
	// record content. Form field name stays as "cf_server_ip" for
	// backwards compatibility with bookmarked form submissions; the
	// template can submit it under either name.
	newServerIP := strings.TrimSpace(r.FormValue("server_ip"))
	if newServerIP == "" {
		newServerIP = strings.TrimSpace(r.FormValue("cf_server_ip"))
	}
	// Same FormValue/hidden-checkbox bug fix as the other two toggles above.
	// v2.7.1 — previously admins could tick the Cloudflare orange-cloud box
	// and hit Save, and the setting would silently remain off.
	cfProxied := "0"
	for _, v := range r.PostForm["cf_proxied"] {
		if v == "1" {
			cfProxied = "1"
			break
		}
	}
	// Snapshot the current server IP before overwriting — used below to
	// detect whether we need to retarget every managed DNS record.
	oldServerIP := s.serverIP()

	backupSchedEnabled := "0"
	if r.FormValue("backup_schedule_enabled") == "on" {
		backupSchedEnabled = "1"
	}
	oidcEnabled := "0"
	if r.FormValue("oidc_enabled") == "on" {
		oidcEnabled = "1"
	}
	oidcAutoCreate := "0"
	if r.FormValue("oidc_auto_create") == "on" {
		oidcAutoCreate = "1"
	}
	kv := map[string]string{
		settingDNSVerifyResolver: strings.TrimSpace(r.FormValue("dns_verify_resolver")), // issue #98
		settingACMEDNSResolvers:  strings.TrimSpace(r.FormValue("acme_dns_resolvers")),  // issue #116
		settingGlobalIPBlocklist: strings.TrimSpace(r.FormValue("global_ip_blocklist")), // issue #100
		// issue #104: scheduled off-host backups.
		settingBackupScheduleEnabled:  backupSchedEnabled,
		settingBackupScheduleDir:      strings.TrimSpace(r.FormValue("backup_schedule_dir")),
		settingBackupScheduleInterval: strings.TrimSpace(r.FormValue("backup_schedule_interval_hours")),
		settingBackupScheduleKeep:     strings.TrimSpace(r.FormValue("backup_schedule_keep")),
		// issue #106: OIDC / SSO login (client secret handled below, keep-blank).
		settingOIDCEnabled:     oidcEnabled,
		settingOIDCIssuer:      strings.TrimSpace(r.FormValue("oidc_issuer")),
		settingOIDCClientID:    strings.TrimSpace(r.FormValue("oidc_client_id")),
		settingOIDCRedirectURL: strings.TrimSpace(r.FormValue("oidc_redirect_url")),
		settingOIDCAutoCreate:  oidcAutoCreate,
		settingOIDCButtonLabel: strings.TrimSpace(r.FormValue("oidc_button_label")),
		// v2.65.0 (issue #139). An unrecognised role is stored as "" so
		// oidcConfig.defaultRole falls back to view, rather than persisting
		// something no permission check understands.
		settingOIDCDefaultRole: func() string {
			role := strings.ToLower(strings.TrimSpace(r.FormValue("oidc_default_role")))
			if !validOIDCRole(role) {
				return ""
			}
			return role
		}(),
		settingOIDCGroupsClaim:  strings.TrimSpace(r.FormValue("oidc_groups_claim")),
		settingOIDCRoleMappings: strings.TrimSpace(r.FormValue("oidc_role_mappings")),
		// v2.67.0: restrict which verified email domains may use SSO.
		settingOIDCAllowedDomains:  strings.TrimSpace(r.FormValue("oidc_allowed_domains")),
		settingNotifyWebhookURL:    webhookURL,
		settingNotifyWebhookSecret: webhookSecret,
		settingNotifyNtfyURL:       ntfyURL, // v2.12.51
		settingNotifyDaysBefore:    strconv.Itoa(daysBefore),
		settingSMTPHost:            smtpHost,
		// v2.11.15: AI assistant settings.
		settingAIEnabled: func() string {
			for _, v := range r.PostForm["ai_enabled"] {
				if v == "1" {
					return "1"
				}
			}
			return "0"
		}(),
		settingAIProvider: func() string {
			// v2.12.36: clamp to known provider names so a hand-crafted POST
			// can't poison the dispatch with an unknown value.
			switch strings.TrimSpace(r.FormValue("ai_provider")) {
			case "ollama_cloud":
				return "ollama_cloud"
			case "anthropic":
				return "anthropic"
			case "openai":
				return "openai"
			default:
				return "ollama"
			}
		}(),
		settingAIOllamaURL:        strings.TrimSpace(r.FormValue("ai_ollama_url")),
		settingAIOllamaModel:      strings.TrimSpace(r.FormValue("ai_ollama_model")),
		settingAIOllamaCloudModel: strings.TrimSpace(r.FormValue("ai_ollama_cloud_model")),
		settingAIAnthropicModel:   strings.TrimSpace(r.FormValue("ai_anthropic_model")),
		settingAIOpenAIBaseURL:    strings.TrimSpace(r.FormValue("ai_openai_base_url")),
		settingAIOpenAIModel:      strings.TrimSpace(r.FormValue("ai_openai_model")),
		// v2.12.37: API keys deliberately handled below (not in this map) so an
		// empty submission leaves the existing key intact — same pattern as
		// settingSMTPPassword. Rendering the actual key into the form (as
		// v2.12.36 did) leaked it via F12 → Elements even with type="password".
		settingAISystemPrompt: r.FormValue("ai_system_prompt"), // preserve whitespace + newlines
		// issue #109: per-request AI chat timeout (seconds). Blank/invalid →
		// default; otherwise clamped to the supported range.
		settingAIRequestTimeoutSec: func() string {
			n, err := strconv.Atoi(strings.TrimSpace(r.FormValue("ai_request_timeout_sec")))
			switch {
			case err != nil || n <= 0:
				n = defaultAIRequestTimeoutSec
			case n < aiRequestTimeoutMinSec:
				n = aiRequestTimeoutMinSec
			case n > aiRequestTimeoutMaxSec:
				n = aiRequestTimeoutMaxSec
			}
			return strconv.Itoa(n)
		}(),
		settingGlobalStripResponseHeaders: strings.TrimSpace(r.FormValue("global_strip_response_headers")),
		// v2.14.4: disable HTTP/3 / QUIC for compatibility with older Android clients.
		settingDisableHTTP3: func() string {
			if r.FormValue("disable_http3") == "on" {
				return "1"
			}
			return "0"
		}(),
		// v2.64.1 (issue #138): one toggle, written to both keys. The form
		// field is require_totp; settingRequire2FA is its deprecated twin and
		// is kept in step so a downgrade still enforces the policy. Accepts
		// either checkbox convention ("1" or "on") because the two old
		// toggles used one each.
		settingRequireTOTP: requireTOTPFormValue(r),
		settingRequire2FA:  requireTOTPFormValue(r),
		// v2.64.2 (issue #138): switching the policy on retires any earlier
		// "I don't want this" acknowledgement, so turning it off again later
		// raises the recommendation afresh instead of staying silently
		// dismissed from a decision made months ago.
		settingTOTPPolicyDismissed: func() string {
			if requireTOTPFormValue(r) == "1" {
				return "0"
			}
			return mustGetSetting(s.DB, settingTOTPPolicyDismissed)
		}(),
		settingSMTPPort:         smtpPort,
		settingSMTPUsername:     smtpUsername,
		settingSMTPFrom:         smtpFrom,
		settingSMTPTo:           smtpTo,
		settingSMTPSecurity:     smtpSecurity,
		settingSMTPSkipVerify:   smtpSkipVerify,
		settingTurnstileSiteKey: turnstileSiteKey,
		settingCaptchaProvider:  captchaProvider,
		settingRecaptchaSiteKey: recaptchaSiteKey,
		// v2.27.0: turnstile/recaptcha secret keys deliberately handled below
		// (not in this map) so an empty submission preserves the stored key —
		// same keep-blank-to-preserve pattern as settingSMTPPassword and the AI
		// provider keys. Site keys stay here: they are public by design.
		settingRecaptchaMinScore: recaptchaMinScore,
		settingTimezone:          timezone,
		settingServerIP:          newServerIP,
		settingCFProxied:         cfProxied,
		// v2.7.0: visitor analytics
		settingAnalyticsEnabled:         analyticsEnabled,
		settingExpectationsAutoRollback: expectationsAutoRollback, // v2.38.0
		settingAnalyticsIngestTarget:    analyticsTarget,
		settingAnalyticsExcludeIPs:      analyticsExclude,
		settingAnalyticsSoftStart:       analyticsSoftStart,
		settingAnalyticsDialTimeoutSec:  strconv.Itoa(analyticsDialTimeoutSec),
		settingAnalyticsRetentionDays:   strconv.Itoa(analyticsRetention), // v2.43.0
		// v2.10.0: trusted proxies + custom site title
		settingTrustedProxies: strings.TrimSpace(r.FormValue("trusted_proxies")),
		settingSiteTitle:      strings.TrimSpace(r.FormValue("site_title")),
		// v2.11.0: custom favicon + admin IP allowlist
		settingFaviconURL:     strings.TrimSpace(r.FormValue("favicon_url")),
		settingAdminAllowlist: strings.TrimSpace(r.FormValue("admin_allowlist")),
		// v2.12.0: configurable session duration + global catch-all 404
		settingCatchAll404HTML: strings.TrimSpace(r.FormValue("catch_all_404_html")),
		// Global maintenance mode: checkbox → "1"/"0"
		settingGlobalMaintenance: func() string {
			if r.FormValue("global_maintenance") == "1" {
				return "1"
			}
			return "0"
		}(),
	}
	if integrationSettingsPresent {
		for key, value := range fleetIntegrationSettings(accessLogFormCfg, crowdSecFormCfg, metricsFormCfg, r.FormValue("client_ip_headers")) {
			kv[key] = value
		}
		if key := strings.TrimSpace(r.FormValue("crowdsec_api_key")); key != "" {
			kv[settingCrowdSecAPIKey] = key
		}
	}
	// v2.60.0 (issue #123): fallback status for unmatched requests, 4xx/5xx only.
	if _, present := r.Form["fallback_status_code"]; present || r.PostForm.Has("fallback_status_code") {
		raw := strings.TrimSpace(r.FormValue("fallback_status_code"))
		if raw == "" {
			kv[settingFallbackStatus] = ""
		} else if n, ok := parseFallbackStatus(raw); ok {
			kv[settingFallbackStatus] = strconv.Itoa(n)
		} else {
			http.Error(w, "Fallback status code must be a number from 400 to 599", http.StatusBadRequest)
			return
		}
	}
	// v2.62.0 (issue #128): site default language; only known catalogs.
	if _, present := r.Form["default_locale"]; present {
		loc := strings.TrimSpace(r.FormValue("default_locale"))
		if loc != "" && !translations().Has(loc) {
			http.Error(w, "Unknown language", http.StatusBadRequest)
			return
		}
		kv[settingDefaultLocale] = loc
	}
	if sessionDays := strings.TrimSpace(r.FormValue("session_duration_days")); sessionDays != "" {
		kv[settingSessionDays] = sessionDays
	}
	// Auto-sync hours: "0" or empty = disabled.
	if autoSyncHours := strings.TrimSpace(r.FormValue("auto_sync_hours")); autoSyncHours != "" {
		if h, err := strconv.Atoi(autoSyncHours); err == nil && h >= 0 {
			kv[settingAutoSyncHours] = strconv.Itoa(h)
		}
	} else {
		kv[settingAutoSyncHours] = "0"
	}
	// Activity log retention days: 0 = keep forever.
	if aldStr := strings.TrimSpace(r.FormValue("activity_log_days")); aldStr != "" {
		if d, err := strconv.Atoi(aldStr); err == nil && d >= 0 {
			kv[settingActivityLogDays] = strconv.Itoa(d)
		}
	} else {
		kv[settingActivityLogDays] = "0"
	}
	// Max login attempts per IP: 0 = no limit.
	if mlaStr := strings.TrimSpace(r.FormValue("max_login_attempts")); mlaStr != "" {
		if n, err := strconv.Atoi(mlaStr); err == nil && n >= 0 {
			kv[settingMaxLoginAttempts] = strconv.Itoa(n)
		}
	} else {
		kv[settingMaxLoginAttempts] = "0"
	}

	// Walk every registered DNS provider and pick up credential fields
	// from the form. Same keep-blank-to-preserve UX as SMTP password:
	// leaving a field empty keeps the stored value, so users don't have
	// to re-enter secrets just to toggle a checkbox. Non-secret fields
	// (for example Namecheap's API user/client IP and Route 53's region/
	// access-key ID) are always overwritten so users can edit or clear them.
	// v2.44.0: provider credentials and allow-lists only exist on the DNS
	// page; collecting them from any other page would clear them.
	if settingsPage == "" || settingsPage == "dns" {
		for _, d := range dns.Descriptors() {
			for _, c := range d.Credentials {
				v := strings.TrimSpace(r.FormValue(c.Key))
				if v == "" && c.Secret {
					// Empty + secret field → preserve existing value.
					continue
				}
				kv[c.Key] = v
			}
			// v2.4.7: per-provider zone allow-list. Always overwrite — an
			// empty textarea means "remove the allow-list, accept every zone
			// again". Normalised form (lowercase, deduped, comma-separated)
			// is what we persist, even though the textarea offers lines for
			// readability.
			allowRaw := r.FormValue(d.ID + "_zone_allowlist")
			kv[zoneAllowlistKey(d.ID)] = strings.Join(parseZoneAllowlist(allowRaw), ",")
		}
	}
	// v2.44.0: client_ip_headers lives on the Security page but is saved by
	// the integrations bundle above; save it directly when its field posted.
	if _, present := r.PostForm["client_ip_headers"]; present && !integrationSettingsPresent {
		kv[settingClientIPHeaders] = strings.TrimSpace(r.FormValue("client_ip_headers"))
	}
	// v2.53.0 (review finding #8): extra certificate read roots also live on
	// the Security page. Persisted in normalised form — one absolute path per
	// line, de-duplicated, with relative/traversing/"/" entries dropped — so
	// the field always shows exactly what is in effect. An empty textarea
	// means "no extras", falling back to the built-in roots.
	if _, present := r.PostForm["certificate_read_roots"]; present {
		fields := strings.FieldsFunc(r.FormValue("certificate_read_roots"), func(c rune) bool { return c == ',' || c == '\n' })
		kv[settingCertificateReadRoots] = strings.Join(normalizeReadRoots(fields), "\n")
	}
	if _, ok := r.PostForm["dns_profile_name"]; ok {
		if err := s.saveDNSProfiles(s.parseDNSProfilesForm(r)); err != nil {
			http.Error(w, "failed to save DNS profiles: "+err.Error(), http.StatusInternalServerError)
			return
		}
	}

	// SMTP password stays keep-blank-to-preserve.
	if smtpPassword != "" {
		kv[settingSMTPPassword] = smtpPassword
	}
	// issue #106: OIDC client secret — keep-blank-to-preserve, like other secrets.
	if v := r.FormValue("oidc_client_secret"); strings.TrimSpace(v) != "" {
		kv[settingOIDCClientSecret] = strings.TrimSpace(v)
	}
	// v2.27.0: captcha secret keys — same keep-blank-to-preserve pattern. The
	// Settings template no longer renders them into the form, so a blank field
	// means "keep the stored value", not "clear it". To actually clear a key,
	// switch the captcha provider off.
	if k := strings.TrimSpace(r.FormValue("turnstile_secret_key")); k != "" {
		kv[settingTurnstileSecretKey] = k
	}
	if k := strings.TrimSpace(r.FormValue("recaptcha_secret_key")); k != "" {
		kv[settingRecaptchaSecretKey] = k
	}
	// v2.12.37: AI provider API keys — same keep-blank-to-preserve pattern.
	// Only persist when the user typed a fresh value; an empty submission
	// means "keep what's already stored." The Settings template never
	// renders the saved key into the form (F12 leak fix from v2.12.36).
	if k := strings.TrimSpace(r.FormValue("ai_ollama_cloud_api_key")); k != "" {
		kv[settingAIOllamaCloudAPIKey] = k
	}
	if k := strings.TrimSpace(r.FormValue("ai_anthropic_api_key")); k != "" {
		kv[settingAIAnthropicAPIKey] = k
	}
	if k := strings.TrimSpace(r.FormValue("ai_openai_api_key")); k != "" {
		kv[settingAIOpenAIAPIKey] = k
	}
	// v2.12.51: ntfy bearer token — same keep-blank-to-preserve pattern
	// so the value never re-renders into the form.
	if t := strings.TrimSpace(r.FormValue("notify_ntfy_token")); t != "" {
		kv[settingNotifyNtfyToken] = t
	}
	// v2.44.0: keep only the keys the posted page owns.
	if settingsPage != "" {
		for k := range kv {
			if owner, known := settingsKeySection[k]; known && owner != settingsPage {
				delete(kv, k)
			}
		}
	}
	for k, v := range kv {
		if err := models.SetSetting(s.DB, k, v); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	}
	// Hot-apply the timezone: updates the atomic pointer every template
	// fmtDate call reads from, so the redirect back to /settings below
	// already renders in the new zone without waiting for a restart. Error
	// is ignored — we already validated above, so LoadLocation can't fail
	// here barring a race with tzdata being unloaded (won't happen in a
	// container with /usr/share/zoneinfo baked in).
	if settingsPage == "" || settingsPage == "general" {
		_ = setActiveLocation(timezone)
	}
	activityDetail := "notify+smtp"
	if settingsPage != "" {
		activityDetail = "page:" + settingsPage
	}
	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "settings_update", activityDetail, smtpHost, true)

	// v2.7.0: push the analytics toggle through to the live ingest +
	// Caddy admin API after saving to the settings table. Errors are
	// logged but not surfaced as a page error — the saved row stays, and
	// the Settings page's "Analytics" card reflects the current wiring
	// state so the admin can retry. This avoids a half-saved "settings
	// didn't persist, Caddy pivoted anyway" that would be very confusing.
	if settingsPage == "" || settingsPage == "analytics" {
		if err := s.applyAnalyticsToggle(loadAnalyticsConfig(s.DB)); err != nil {
			log.Printf("settings: analytics toggle: %v", err)
		}
		// The certificate monitor shares the analytics ingest target even when
		// visitor analytics itself is disabled. Refresh it after target/timeout
		// changes so lifecycle events keep flowing to the right listener.
		if err := s.ReconcileCertificateLogs(); err != nil {
			log.Printf("settings: certificate log monitoring: %v", err)
		}
	}

	// v2.4.0: per-server public IP update. The settings form submits one
	// server_ip_<id> field per Caddy server. For each one that changed,
	// retarget only that server's managed DNS records in the background.
	//
	// We collect both the IDs whose IP changed and the new IP each maps to,
	// then fan out one goroutine per server. No fan-out if nothing changed.
	if servers, err := models.ListCaddyServers(s.DB); err == nil {
		type retarget struct {
			serverID int64
			newIP    string
		}
		var pending []retarget
		for _, sr := range servers {
			field := fmt.Sprintf("server_ip_%d", sr.ID)
			// Only act if the form actually submitted this field — guards
			// against accidental wipes from partial form POSTs.
			if _, ok := r.Form[field]; !ok {
				continue
			}
			newIP := strings.TrimSpace(r.FormValue(field))
			old, err := models.SetCaddyServerPublicIP(s.DB, sr.ID, newIP)
			if err != nil {
				log.Printf("settings: save public_ip for server %d: %v", sr.ID, err)
				continue
			}
			if newIP != "" && newIP != strings.TrimSpace(old) {
				pending = append(pending, retarget{serverID: sr.ID, newIP: newIP})
			}
		}
		for _, t := range pending {
			sid, ip := t.serverID, t.newIP
			go s.dnsUpdateAllRecords(sid, ip)
		}
	}

	// Legacy global-IP fallback retarget: only fires when the per-server
	// table is empty (brand-new databases or users who haven't filled in
	// the new per-server column). Passing serverID=0 walks every managed
	// host so pre-v2.4.0 behaviour still works.
	if newServerIP != "" && newServerIP != oldServerIP {
		go s.dnsUpdateAllRecords(0, newServerIP)
	}

	// v2.12.18: auto-sync Caddy after a settings save so changes that
	// affect the live config (global strip-headers, catch-all 404 HTML,
	// global maintenance toggle, etc.) take effect immediately. Without
	// this, users had to manually click Sync Caddy after Save and
	// wondered why their setting "didn't work." Best-effort — failures
	// are logged but don't block the redirect.
	serverIDsToSync := s.settingsSyncServerIDs(s.currentServerID(r))
	if settingsSectionSyncsCaddy(settingsPage) { // v2.44.0
		go func(serverIDs []int64) {
			// syncCaddy temporarily swaps s.Caddy, so fleet syncs stay sequential.
			for _, serverID := range serverIDs {
				if err := s.syncCaddy(serverID, false); err != nil {
					log.Printf("settings: auto-sync server %d after save failed (non-fatal): %v", serverID, err)
				}
			}
		}(serverIDsToSync)
	}

	if settingsPage != "" {
		http.Redirect(w, r, "/settings/"+settingsPage+"?saved=1", http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
}

// settingsSyncServerIDs is every server a settings save re-syncs: all managed
// ones, the selected server first. Settings are global — the fleet-wide IP
// blocklist, global maintenance, the fallback page, stripped headers and the
// ACME resolvers go into every server's config — but before v2.68.3 (issue
// #150) only the selected server was re-synced unless the Integrations page
// was saved, so the other nodes kept serving without the change, e.g. without
// a newly blocked address, until something else synced them.
func (s *Server) settingsSyncServerIDs(currentID int64) []int64 {
	ids := []int64{}
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		log.Printf("settings: list servers to sync: %v", err)
		return []int64{currentID}
	}
	for _, srv := range servers {
		if srv.Type == models.CaddyServerTypeExternal {
			continue
		}
		if srv.ID == currentID {
			ids = append([]int64{srv.ID}, ids...)
		} else {
			ids = append(ids, srv.ID)
		}
	}
	return ids
}

// postClearDNSProvider wipes every stored credential for a single DNS
// provider (and its provider-specific flags, e.g. Cloudflare's cf_proxied
// toggle). The provider ID comes from the URL path. Storing an empty string
// via SetSetting is how we "delete" — GetSetting returns "" for both
// "never written" and "written as empty", and every dns.Build call treats
// empty strings as "provider not configured".
//
// Admin-gated at the router level; also logs an audit entry so a
// credential wipe is traceable the same way a save is.
func (s *Server) postClearDNSProvider(w http.ResponseWriter, r *http.Request) {
	id := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "id")))
	keys, ok := dnsProviderCredKeys[id]
	if !ok {
		http.Error(w, "unknown DNS provider", http.StatusBadRequest)
		return
	}

	for _, k := range keys {
		if err := models.SetSetting(s.DB, k, ""); err != nil {
			http.Error(w, "failed to clear credentials: "+err.Error(), http.StatusInternalServerError)
			return
		}
	}
	// Cloudflare also stores a per-provider flag (orange-cloud proxied) that
	// should be reset alongside the API token — otherwise re-entering a
	// token later would silently inherit the previous proxied state.
	if id == dns.Cloudflare {
		_ = models.SetSetting(s.DB, settingCFProxied, "")
	}
	// v2.4.7: also clear the zone allow-list. A user wiping the credentials
	// usually wants a clean slate — a stale allow-list sitting around means
	// the next set of keys they enter would silently be restricted by rules
	// they've forgotten about.
	_ = models.SetSetting(s.DB, zoneAllowlistKey(id), "")

	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r),
		"dns_provider_clear", "dns:"+id, "", true)

	http.Redirect(w, r, "/settings/dns?cleared="+url.QueryEscape(id), http.StatusSeeOther)
}

func (s *Server) postTestWebhook(w http.ResponseWriter, r *http.Request) {
	// Always read the webhook URL from the trusted database — never from the
	// request body — to prevent server-side request forgery (SSRF).
	webhookURL, _ := models.GetSetting(s.DB, settingNotifyWebhookURL)
	if webhookURL == "" {
		http.Error(w, "No webhook URL configured. Save your settings first.", http.StatusBadRequest)
		return
	}
	// Validate that the stored URL is a safe http/https endpoint.
	parsedWebhook, parseErr := url.Parse(webhookURL)
	if parseErr != nil || (parsedWebhook.Scheme != "http" && parsedWebhook.Scheme != "https") || parsedWebhook.Host == "" {
		http.Error(w, "Invalid webhook URL in settings — must begin with http:// or https://", http.StatusBadRequest)
		return
	}
	payload := map[string]any{
		"event":     "test",
		"message":   "CaddyUI webhook test",
		"timestamp": time.Now().UTC().Format(time.RFC3339),
	}
	body, _ := json.Marshal(payload)
	resp, err := http.Post(parsedWebhook.String(), "application/json", bytes.NewReader(body))
	if err != nil {
		http.Error(w, "Webhook POST failed: "+err.Error(), http.StatusBadGateway)
		return
	}
	_ = resp.Body.Close()
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"ok":          true,
		"status_code": resp.StatusCode,
	})
}

func (s *Server) postTestEmail(w http.ResponseWriter, r *http.Request) {
	subject := "[CaddyUI] Test email"
	body := fmt.Sprintf(
		"This is a test notification from CaddyUI.\n\nIf you received this, your SMTP configuration is working correctly.\n\nSent at: %s\n",
		time.Now().UTC().Format(time.RFC3339),
	)
	if err := sendEmail(s.DB, subject, body); err != nil {
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error()})
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}