Ctrl K
ComunidadGuías…

internal/server/caddy_servers.go

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

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

package server

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/X4Applegate/caddyui/internal/caddy"
	"github.com/X4Applegate/caddyui/internal/models"
	"github.com/go-chi/chi/v5"
)

// isValidAdminURL returns true for URLs CaddyUI knows how to dial:
//   - http:// and https:// for standard TCP (optionally wrapped in TLS)
//   - unix:// for a Unix domain socket path (e.g. unix:///run/caddy-admin.sock)
//
// Empty strings and anything else (file://, ftp://, bare hostnames) are rejected.
func isValidAdminURL(u string) bool {
	return strings.HasPrefix(u, "http://") ||
		strings.HasPrefix(u, "https://") ||
		strings.HasPrefix(u, "unix://")
}

// SeedBootstrapServer inserts the first Caddy server using the admin URL the
// process was launched with. No-op once any server row exists — idempotent so
// restarts don't resurrect deleted rows. Optional username/password seed the
// bootstrap server with HTTP Basic Auth credentials (from CADDY_ADMIN_USER /
// CADDY_ADMIN_PASS env vars) for setups that gate port 2019 behind a reverse
// proxy that enforces basic auth.
func (s *Server) SeedBootstrapServer(adminURL, username, password string) error {
	n, err := models.CountCaddyServers(s.DB)
	if err != nil {
		return err
	}
	if n > 0 {
		return nil
	}
	_, err = models.CreateCaddyServer(s.DB, &models.CaddyServer{
		Name:          "Primary",
		AdminURL:      adminURL,
		Type:          models.CaddyServerTypeManaged,
		AdminUsername: username,
		AdminPassword: password,
	})
	return err
}

// --- Handlers ---

func (s *Server) listServersPage(w http.ResponseWriter, r *http.Request) {
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	// v2.37.0: effective log-ingest target per managed node, plus a warning
	// when a remote node was handed a bare Docker service name it can't
	// resolve — the silent cause of "analytics is empty for that server".
	analyticsCfg := loadAnalyticsConfig(s.DB)
	ingestTargets := map[int64]string{}
	ingestWarnings := map[int64]string{}
	for _, sr := range servers {
		if sr.Type != models.CaddyServerTypeManaged {
			continue
		}
		target := sr.EffectiveIngestTarget(analyticsCfg.Target)
		ingestTargets[sr.ID] = target
		if warn := sr.IngestTargetWarning(target); warn != "" {
			ingestWarnings[sr.ID] = warn
		}
	}
	s.render(w, r, "servers.html", map[string]any{
		"User":           s.currentUser(r),
		"Servers":        servers,
		"IngestTargets":  ingestTargets,
		"IngestWarnings": ingestWarnings,
		"Section":        "servers",
		"Flash":          strings.TrimSpace(r.URL.Query().Get("flash")),
		"Error":          strings.TrimSpace(r.URL.Query().Get("error")),
	})
}

// syncServerFromCurrent performs a one-way merge of every CaddyUI-managed
// route from the environment selected in the application shell into the target
// fleet member. Target-only rows, target DNS records, and custom certificate
// choices stay intact; repeated runs update the same paired rows.
func (s *Server) syncServerFromCurrent(w http.ResponseWriter, r *http.Request) {
	targetServerID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if err != nil || targetServerID <= 0 {
		http.Error(w, "invalid target environment", http.StatusBadRequest)
		return
	}
	sourceServerID := s.currentServerID(r)
	source, target, err := s.validateFleetPair(sourceServerID, targetServerID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	summary, copyErr := s.syncFleetConfiguration(s.currentUserEmail(r), sourceServerID, targetServerID)
	applyErr := s.syncCaddy(targetServerID, summary.CertificatesCreated+summary.CertificatesUpdated > 0)
	if copyErr != nil || applyErr != nil {
		parts := []string{fmt.Sprintf("Configuration sync from %s to %s was incomplete (%s).", source.Name, target.Name, summary.String())}
		if copyErr != nil {
			parts = append(parts, copyErr.Error())
		}
		if applyErr != nil {
			parts = append(parts, "Caddy apply failed: "+applyErr.Error())
		}
		http.Redirect(w, r, "/servers?error="+url.QueryEscape(strings.Join(parts, " ")), http.StatusSeeOther)
		return
	}
	message := fmt.Sprintf("Synced configuration from %s to %s — %s. Target-only routes, DNS records, and custom certificate choices were preserved.", source.Name, target.Name, summary.String())
	http.Redirect(w, r, "/servers?flash="+url.QueryEscape(message), http.StatusSeeOther)
}

func (s *Server) newServerPage(w http.ResponseWriter, r *http.Request) {
	s.render(w, r, "server_form.html", map[string]any{
		"User":    s.currentUser(r),
		"Target":  &models.CaddyServer{Type: models.CaddyServerTypeManaged},
		"Section": "servers",
		// v2.56.0 (issue #113): "Also copy to" for the layer4 Caddyfile field.
		// No server ID exists yet, so nothing needs to be excluded besides 0.
		"OtherServers": s.otherManagedServersExcept(0),
	})
}

func (s *Server) createServer(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	c := &models.CaddyServer{
		Name:            strings.TrimSpace(r.FormValue("name")),
		AdminURL:        strings.TrimSpace(r.FormValue("admin_url")),
		Type:            r.FormValue("type"),
		Tags:            strings.TrimSpace(r.FormValue("tags")),
		Version:         strings.TrimSpace(r.FormValue("version")),
		AdminUsername:   strings.TrimSpace(r.FormValue("admin_username")),
		AdminPassword:   r.FormValue("admin_password"),
		IngestTarget:    strings.TrimSpace(r.FormValue("ingest_target")),    // v2.37.0
		DataDir:         strings.TrimSpace(r.FormValue("data_dir")),         // v2.42.0
		Layer4Caddyfile: strings.TrimSpace(r.FormValue("layer4_caddyfile")), // v2.56.0 (issue #113)
		// v2.57.0 (issue #120): persistent default deploy targets. Not
		// "deploy_to" — on this form that is the layer4 "Also copy to" picker.
		DefaultDeployTargets: s.parseDefaultDeployTargets(r, 0),
		PropagateDeletions:   r.FormValue("propagate_deletions") != "", // v2.59.0 (issue #120)
	}
	renderErr := func(msg string) {
		s.render(w, r, "server_form.html", map[string]any{
			"User":         s.currentUser(r),
			"Target":       c,
			"Section":      "servers",
			"Error":        msg,
			"OtherServers": s.otherManagedServersExcept(0),
		})
	}
	if c.Name == "" {
		renderErr("Name is required")
		return
	}
	if c.AdminURL == "" {
		renderErr("Admin URL is required (e.g. http://10.0.0.2:2019 or unix:///run/caddy-admin.sock)")
		return
	}
	if !isValidAdminURL(c.AdminURL) {
		renderErr("Admin URL must start with http://, https://, or unix:///")
		return
	}
	// v2.56.0 (issue #113): adapt the pasted layer4 block against this
	// server's own admin API right now, so a typo or a Caddy build missing
	// the caddy-l4 module surfaces here instead of silently breaking every
	// sync of this server from now on.
	if c.Layer4Caddyfile != "" {
		if _, err := buildLayer4App(caddy.New(c.AdminURL, c.AdminUsername, c.AdminPassword), c.Layer4Caddyfile); err != nil {
			renderErr("Layer4 Caddyfile rejected by Caddy: " + err.Error())
			return
		}
	}
	id, err := models.CreateCaddyServer(s.DB, c)
	if err != nil {
		renderErr(err.Error())
		return
	}
	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "server_create", fmt.Sprintf("server:%d", id), c.Name, true)
	go func() {
		if err := s.ReconcileAnalyticsAccessLogs(); err != nil {
			log.Printf("server create: analytics log monitoring: %v", err)
		}
		if err := s.ReconcileCertificateLogs(); err != nil {
			log.Printf("server create: certificate log monitoring: %v", err)
		}
	}()
	// v2.56.0 (issue #113): copy the same layer4 Caddyfile text to any
	// selected fleet targets, same "Also deploy to" UX family as proxy
	// hosts/redirects/raw routes.
	if deployTo := parseDeployTo(r); len(deployTo) > 0 {
		s.crossDeployLayer4Caddyfile(s.currentUserEmail(r), id, c.Layer4Caddyfile, deployTo)
	}
	http.Redirect(w, r, "/servers", http.StatusSeeOther)
}

func (s *Server) editServerPage(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	c, err := models.GetCaddyServer(s.DB, id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	pending, _ := models.ListFleetPendingDeletions(s.DB, id) // v2.59.0 (issue #120)
	s.render(w, r, "server_form.html", map[string]any{
		"User":             s.currentUser(r),
		"Target":           c,
		"Section":          "servers",
		"OtherServers":     s.otherManagedServersExcept(id), // v2.56.0 (issue #113)
		"PendingDeletions": pending,
		"Flash":            r.URL.Query().Get("flash"),
	})
}

func (s *Server) updateServer(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	existing, err := models.GetCaddyServer(s.DB, id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	previous := *existing
	_ = r.ParseForm()
	existing.Name = strings.TrimSpace(r.FormValue("name"))
	existing.AdminURL = strings.TrimSpace(r.FormValue("admin_url"))
	existing.Type = r.FormValue("type")
	existing.Tags = strings.TrimSpace(r.FormValue("tags"))
	existing.Version = strings.TrimSpace(r.FormValue("version"))
	existing.AdminUsername = strings.TrimSpace(r.FormValue("admin_username"))
	existing.IngestTarget = strings.TrimSpace(r.FormValue("ingest_target"))       // v2.37.0
	existing.DataDir = strings.TrimSpace(r.FormValue("data_dir"))                 // v2.42.0
	existing.Layer4Caddyfile = strings.TrimSpace(r.FormValue("layer4_caddyfile")) // v2.56.0 (issue #113)
	existing.DefaultDeployTargets = s.parseDefaultDeployTargets(r, id)            // v2.57.0 (issue #120)
	existing.PropagateDeletions = r.FormValue("propagate_deletions") != ""        // v2.59.0 (issue #120)
	// Password: if the form submitted a blank value AND the user didn't explicitly
	// check the "clear password" box, keep the existing one. Protects against
	// masked-field UX where the password isn't re-typed on every edit.
	if newPw := r.FormValue("admin_password"); newPw != "" {
		existing.AdminPassword = newPw
	} else if r.FormValue("clear_admin_password") == "1" {
		existing.AdminPassword = ""
	}
	renderErr := func(msg string) {
		s.render(w, r, "server_form.html", map[string]any{
			"User":         s.currentUser(r),
			"Target":       existing,
			"Section":      "servers",
			"Error":        msg,
			"OtherServers": s.otherManagedServersExcept(id),
		})
	}
	if existing.Name == "" {
		renderErr("Name is required")
		return
	}
	if existing.AdminURL == "" || !isValidAdminURL(existing.AdminURL) {
		renderErr("Admin URL must start with http://, https://, or unix:///")
		return
	}
	if s.caddyLogHub != nil {
		if _, active := s.caddyLogHub.Capture(id); active {
			if err := s.disableRuntimeLogCapture(previous); err != nil {
				renderErr("Stop full server-log capture before changing this server: " + err.Error())
				return
			}
		}
	}
	// v2.56.0 (issue #113): validate against this server's own (possibly
	// just-edited) admin URL/credentials before saving — see createServer.
	if existing.Layer4Caddyfile != "" {
		if _, err := buildLayer4App(caddy.New(existing.AdminURL, existing.AdminUsername, existing.AdminPassword), existing.Layer4Caddyfile); err != nil {
			renderErr("Layer4 Caddyfile rejected by Caddy: " + err.Error())
			return
		}
	}
	// v2.57.0 (issue #120): two servers that each deploy to the other would
	// overwrite one another's copies on every save — refuse the loop outright
	// instead of leaving it to a warning in the form text.
	for _, targetID := range existing.DefaultDeployTargetIDs() {
		if t, err := models.GetCaddyServer(s.DB, targetID); err == nil && t.DefaultDeployTargetSet()[id] {
			renderErr(fmt.Sprintf("%s already deploys to this server automatically. Pointing two servers at each other would make each save overwrite the other — remove %s from %s's targets, or untick it here.", t.Name, existing.Name, t.Name))
			return
		}
	}
	if err := models.UpdateCaddyServer(s.DB, existing); err != nil {
		renderErr(err.Error())
		return
	}
	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "server_update", fmt.Sprintf("server:%d", id), existing.Name, true)
	go func() {
		if err := s.ReconcileAnalyticsAccessLogs(); err != nil {
			log.Printf("server update: analytics log monitoring: %v", err)
		}
		if err := s.ReconcileCertificateLogs(); err != nil {
			log.Printf("server update: certificate log monitoring: %v", err)
		}
	}()
	// v2.56.0 (issue #113): re-deploy the edited layer4 Caddyfile to any
	// selected targets, mirroring the raw-route/proxy-host edit flow.
	if deployTo := parseDeployTo(r); len(deployTo) > 0 {
		s.crossDeployLayer4Caddyfile(s.currentUserEmail(r), id, existing.Layer4Caddyfile, deployTo)
	}
	if msg := s.newDeployTargetsNotice(previous, *existing); msg != "" {
		http.Redirect(w, r, "/servers?flash="+url.QueryEscape(msg), http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/servers", http.StatusSeeOther)
}

// newDeployTargetsNotice explains, when a save adds automatic deployment
// targets, that what already exists on the source was not copied. Targets
// receive what is saved from then on; existing resources need one Sync from
// current. Without this, ticking a target looked like it had not worked
// (issue #150). Empty when no target was added or nothing exists to copy.
func (s *Server) newDeployTargetsNotice(before, after models.CaddyServer) string {
	had := before.DefaultDeployTargetSet()
	var added []string
	for _, id := range after.DefaultDeployTargetIDs() {
		if had[id] {
			continue
		}
		if t, err := models.GetCaddyServer(s.DB, id); err == nil && t != nil {
			added = append(added, t.Name)
		}
	}
	if len(added) == 0 {
		return ""
	}
	existing := 0
	if rows, err := models.ListProxyHosts(s.DB, after.ID, 0, true, nil); err == nil {
		for _, p := range rows {
			if !p.NodeLocal {
				existing++
			}
		}
	}
	if rows, err := models.ListRedirectionHosts(s.DB, after.ID, 0, true, nil); err == nil {
		existing += len(rows)
	}
	if rows, err := models.ListRawRoutes(s.DB, after.ID, 0, true, nil); err == nil {
		for _, rr := range rows {
			if !rr.NodeLocal {
				existing++
			}
		}
	}
	if existing == 0 {
		return ""
	}
	targets := strings.Join(added, ", ")
	return fmt.Sprintf("Saved. From now on, every proxy host, redirection and advanced route saved on %s is also deployed to %s. "+
		"The %d that already exist on %s were not copied: select %s as the current server, then use Sync from current on %s once.",
		after.Name, targets, existing, after.Name, after.Name, targets)
}

// parseDefaultDeployTargets reads the server form's "default_deploy_targets"
// checkboxes (v2.57.0, issue #120) and returns them as the stored
// comma-separated ID list. Only existing managed servers other than selfID
// survive, so a forged or stale ID can neither point a server at itself nor at
// a monitoring-only or deleted server. IDs are stored ascending so re-saving an
// unchanged form never rewrites the column.
func (s *Server) parseDefaultDeployTargets(r *http.Request, selfID int64) string {
	allowed := map[int64]bool{}
	for _, srv := range s.otherManagedServersExcept(selfID) {
		allowed[srv.ID] = true
	}
	var ids []int64
	seen := map[int64]bool{}
	for _, v := range r.Form["default_deploy_targets"] {
		id, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
		if err != nil || !allowed[id] || seen[id] {
			continue
		}
		seen[id] = true
		ids = append(ids, id)
	}
	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
	return models.JoinServerIDs(ids)
}

// crossDeployLayer4Caddyfile copies the source server's pasted layer4
// Caddyfile text to each target server's own Layer4Caddyfile field (v2.56.0,
// issue #113). Unlike proxy/redirect/raw-route cross-deploy there is no
// separate resource row to create — this sets the field directly on each
// target CaddyServer row and re-syncs it. Each target is validated against
// its OWN admin API before being written: fleet members can run different
// Caddy builds, and a target without caddy-l4 compiled in is skipped (with
// the reason logged to its activity log) rather than silently broken on its
// next scheduled sync. Best-effort per target, matching crossDeployProxyHost
// and crossDeployRawRoute — one bad target must not abort the others.
func (s *Server) crossDeployLayer4Caddyfile(actor string, sourceServerID int64, caddyfileSrc string, serverIDs []int64) {
	s.fleetDeployMu.Lock()
	defer s.fleetDeployMu.Unlock()

	for _, sid := range serverIDs {
		_, target, err := s.validateFleetPair(sourceServerID, sid)
		if err != nil {
			log.Printf("cross-deploy layer4 target %d: %v", sid, err)
			_ = models.LogActivity(s.DB, sourceServerID, actor, "layer4_cross_deploy", fmt.Sprintf("server:%d", sid), err.Error(), false)
			continue
		}
		caddyCl := caddy.New(target.AdminURL, target.AdminUsername, target.AdminPassword)
		if _, err := buildLayer4App(caddyCl, caddyfileSrc); err != nil {
			log.Printf("cross-deploy layer4 target %d rejected: %v", sid, err)
			_ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", "layer4", "rejected by Caddy: "+err.Error(), false)
			continue
		}
		target.Layer4Caddyfile = caddyfileSrc
		if err := models.UpdateCaddyServer(s.DB, target); err != nil {
			log.Printf("cross-deploy layer4 target %d save: %v", sid, err)
			_ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", "layer4", err.Error(), false)
			continue
		}
		_ = models.LogActivity(s.DB, sid, actor, "layer4_cross_deploy", "layer4", "updated layer4 Caddyfile", true)
		if err := s.syncCaddy(sid, false); err != nil {
			log.Printf("cross-deploy layer4 sync server %d: %v", sid, err)
		}
	}
}

func (s *Server) deleteServer(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	n, _ := models.CountCaddyServers(s.DB)
	if n <= 1 {
		http.Error(w, "can't delete the last server — add another first", http.StatusBadRequest)
		return
	}
	c, err := models.GetCaddyServer(s.DB, id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	if s.caddyLogHub != nil {
		if _, active := s.caddyLogHub.Capture(id); active {
			if err := s.disableRuntimeLogCapture(*c); err != nil {
				http.Error(w, "stop full server-log capture before deleting this server: "+err.Error(), http.StatusBadGateway)
				return
			}
		}
	}
	if err := models.DeleteCaddyServer(s.DB, id); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	_ = models.LogActivity(s.DB, s.currentServerID(r), s.currentUserEmail(r), "server_delete", fmt.Sprintf("server:%d", id), c.Name, true)
	http.Redirect(w, r, "/servers", http.StatusSeeOther)
}

// selectServer sets the caddyui_server cookie to the given server ID and
// redirects back to wherever the user came from (or "/" if Referer is absent).
func (s *Server) selectServer(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if err != nil || id <= 0 {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	// Validate the server actually exists.
	if _, err := models.GetCaddyServer(s.DB, id); err != nil {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
	http.SetCookie(w, &http.Cookie{
		Name:     serverCookie,
		Value:    strconv.FormatInt(id, 10),
		Path:     "/",
		MaxAge:   60 * 60 * 24 * 365, // 1 year
		HttpOnly: true,
		Secure:   secure,
		SameSite: http.SameSiteLaxMode,
	})
	ref := r.Header.Get("Referer")
	if ref == "" {
		ref = "/"
	}
	http.Redirect(w, r, ref, http.StatusSeeOther)
}

// viewServerConfig streams the live /config/ JSON from the selected Caddy server so
// the operator can eyeball what's actually running on that box without SSH-ing.
func (s *Server) viewServerConfig(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	c, err := models.GetCaddyServer(s.DB, id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusNotFound)
		return
	}
	// Use a caddy.Client so the read respects auth + unix-socket transport.
	cc := caddy.New(c.AdminURL, c.AdminUsername, c.AdminPassword)
	_, raw, err := cc.FetchConfig()
	if err != nil {
		s.render(w, r, "server_config.html", map[string]any{
			"User":    s.currentUser(r),
			"Target":  c,
			"Error":   err.Error(),
			"Section": "servers",
		})
		return
	}
	pretty := raw
	var v any
	if err := json.Unmarshal([]byte(raw), &v); err == nil {
		if b, err := json.MarshalIndent(v, "", "  "); err == nil {
			pretty = string(b)
		}
	}
	s.render(w, r, "server_config.html", map[string]any{
		"User":    s.currentUser(r),
		"Target":  c,
		"Config":  pretty,
		"Status":  200,
		"Section": "servers",
	})
}

// --- Health poller ---

// StartHealthPoller launches a goroutine that pings every server's admin API on
// an interval and records status + last-contact. Cheap — one GET per server per tick.
// Runs until ctx cancels. Cadence is fixed at 30s; easy to env-ify later if needed.
func (s *Server) StartHealthPoller(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(30 * time.Second)
		defer ticker.Stop()
		s.pollAllServers(ctx)
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				s.pollAllServers(ctx)
			}
		}
	}()
}

func (s *Server) pollAllServers(ctx context.Context) {
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		log.Printf("health poller: list servers: %v", err)
		return
	}
	for _, srv := range servers {
		s.pollOneServer(ctx, srv)
	}
}

func (s *Server) pollOneServer(ctx context.Context, srv models.CaddyServer) {
	// Build a caddy.Client per ping so we pick up any admin URL / credential
	// changes made via the UI since the last tick, and so the ping goes
	// through the same transport + auth path as real requests (unix sockets
	// and basic-auth-wrapped endpoints both work out of the box).
	client := caddy.New(srv.AdminURL, srv.AdminUsername, srv.AdminPassword)
	// 8s per ping is comfortable over WireGuard/Tailscale. Below that a
	// single dropped UDP packet during rekey can exceed the window and flap
	// the server.
	pingCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
	defer cancel()
	code, err := client.Ping(pingCtx)
	failed := err != nil || code >= 500

	s.healthMu.Lock()
	defer s.healthMu.Unlock()
	if failed {
		s.healthFailures[srv.ID]++
		// Only flip to offline once we've missed healthFailThreshold pings
		// in a row. Before that, keep the existing status so the dashboard
		// doesn't flap on transient blips. Still update last-contact is
		// intentionally NOT set — we didn't actually contact the server.
		if s.healthFailures[srv.ID] >= healthFailThreshold {
			_ = models.SetCaddyServerStatus(s.DB, srv.ID, models.CaddyServerStatusOffline, nil)
		}
		return
	}
	// Success → reset the failure counter and mark online with a fresh
	// last-contact timestamp.
	s.healthFailures[srv.ID] = 0
	now := time.Now()
	_ = models.SetCaddyServerStatus(s.DB, srv.ID, models.CaddyServerStatusOnline, &now)
}