Ctrl K
ComunidadGuías…

internal/server/upstream_health_notifier.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/hmac"
	"crypto/sha256"
	"crypto/tls"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/X4Applegate/caddyui/internal/caddy"
	"github.com/X4Applegate/caddyui/internal/models"
)

// --- Upstream health notifier ---

// upstreamAlertEntry records a single upstream state-change notification.
type upstreamAlertEntry struct {
	ServerName string    `json:"server"`
	Upstream   string    `json:"upstream"`
	Event      string    `json:"event"` // "down" or "recovered"
	AlertedAt  time.Time `json:"alerted_at"`
}

// upstreamNotifyState tracks upstream health across check intervals so we can
// detect transitions (healthy→down and down→recovered) and avoid duplicate alerts.
var upstreamNotifyState struct {
	mu        sync.Mutex
	prevFails map[string]bool // key "serverID:address" → was failing on last check?
	lastCheck time.Time
	recent    []upstreamAlertEntry
}

// runHealthChecker polls each enabled proxy host every 5 minutes and records
// the result in proxy_health — the persisted "Public" health history behind
// /proxy-hosts/{id}/health and the left-most dot on the proxy hosts list. It
// checks the first domain of each host via HTTPS (falling back to HTTP if
// ssl_enabled is false). Runs until the process exits.
//
// v2.35.0 (issue #39 follow-up): this is a *third* probe CaddyUI runs on a
// host's behalf, alongside the App and Port dots that v2.28.0 made
// configurable. It was never wired to MonitorMode, so a host set to "Off" —
// explicitly promised to stop "all outbound probes for this host" — kept
// getting hit here every 5 minutes regardless, and a host in "Custom" mode
// with a non-default path or expected status still showed this dot red
// because the check always requested "/" and only accepted <400/401/403.
// That's the exact failure mode the issue described. Now it honors the same
// MonitorSettings as the other two probes.
//
// NodeLocal is deliberately NOT consulted here. It says the *upstream*
// (forward_host/port) only resolves on this node — it says nothing about
// whether the host's public domain should be checked from the internet-facing
// side, which is what this probe does. Only an explicit MonitorMode of "off"
// suppresses it.
func (s *Server) runHealthChecker() {
	// Stagger the first check by 30 seconds so startup load doesn't spike.
	time.Sleep(30 * time.Second)
	ticker := time.NewTicker(5 * time.Minute)
	defer ticker.Stop()
	s.checkAllProxyHosts()
	for range ticker.C {
		s.checkAllProxyHosts()
	}
}

// publicHealthCheckerInterval is this poller's native cadence, and therefore
// the "Automatic" default fed to MonitorSettings — distinct from the App
// dot's 60s default, since this checker has always run every 5 minutes.
//
// publicHealthCheckerTimeout matches the client this poller has always used
// (short timeout, no redirect following) — the historical "Automatic"
// default for MonitorSettings, same as the interval above.
const (
	publicHealthCheckerInterval = 5 * time.Minute
	publicHealthCheckerTimeout  = 10 * time.Second
)

func (s *Server) checkAllProxyHosts() {
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil {
		log.Printf("health checker: list servers: %v", err)
		return
	}
	for _, srv := range servers {
		hosts, err := models.ListProxyHosts(s.DB, srv.ID, 0, true, nil)
		if err != nil {
			log.Printf("health checker: list hosts for server %d: %v", srv.ID, err)
			continue
		}
		for _, h := range hosts {
			if !h.Enabled {
				continue
			}
			// v2.35.0: "Off" means no outbound probe of any kind — see the
			// package comment on runHealthChecker.
			if h.MonitoringDisabled() {
				continue
			}
			domains := h.DomainList()
			if len(domains) == 0 {
				continue
			}
			domain := domains[0]
			scheme := "https"
			if !h.SSLEnabled {
				scheme = "http"
			}
			path, method, expectStatus, interval, timeout := h.MonitorSettings(publicHealthCheckerInterval, publicHealthCheckerTimeout)
			if !s.publicHealthDue(h.ID, interval) {
				continue
			}
			targetURL := scheme + "://" + domain + path
			s.checkProxyHost(h.ID, targetURL, method, expectStatus, timeout)
		}
	}
}

// publicHealthCadenceLabel describes the effective cadence of the Public
// health checker for the /proxy-hosts/{id}/health page, since it's no longer
// unconditionally "every 5 min" once Custom mode can set a longer interval.
func publicHealthCadenceLabel(h models.ProxyHost) string {
	if !strings.EqualFold(strings.TrimSpace(h.MonitorMode), "custom") || h.MonitorIntervalSec <= 0 {
		return "every 5 min"
	}
	interval := time.Duration(h.MonitorIntervalSec) * time.Second
	if interval <= publicHealthCheckerInterval {
		return "every 5 min" // custom value floors to the native tick anyway
	}
	minutes := int(interval / time.Minute)
	return fmt.Sprintf("roughly every %d min (custom)", minutes)
}

// publicHealthDue reports whether hostID's persisted public health check is
// due for a new probe, honoring a custom interval longer than the poller's
// native 5-minute tick (a shorter one can't be honoured — the ticker itself
// only fires every 5 minutes). Paced off the most recently persisted check
// rather than separate in-memory state, so — unlike the App dot's pacing —
// this survives a process restart instead of probing immediately on boot
// regardless of how recently it last ran.
func (s *Server) publicHealthDue(hostID int64, interval time.Duration) bool {
	if interval <= publicHealthCheckerInterval {
		return true
	}
	history, err := models.GetProxyHealthHistory(s.DB, hostID, 1)
	if err != nil || len(history) == 0 {
		return true
	}
	// Subtract half a tick so a host configured at exactly 2x the interval
	// isn't pushed out to 3x by scheduling jitter, matching appProbeDue.
	return time.Since(history[0].CheckedAt) >= interval-(publicHealthCheckerInterval/2)
}

func (s *Server) checkProxyHost(hostID int64, targetURL, method string, expectStatus int, timeout time.Duration) {
	client := &http.Client{
		Timeout: timeout,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse // don't follow redirects
		},
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // check reachability, not cert validity
		},
	}
	defer client.CloseIdleConnections()
	req, err := http.NewRequest(method, targetURL, nil)
	if err != nil {
		_ = models.InsertProxyHealth(s.DB, hostID, false, 0, 0, err.Error())
		return
	}
	start := time.Now()
	resp, err := client.Do(req)
	latencyMs := time.Since(start).Milliseconds()
	if err != nil {
		errMsg := err.Error()
		if len(errMsg) > 200 {
			errMsg = errMsg[:200]
		}
		_ = models.InsertProxyHealth(s.DB, hostID, false, 0, latencyMs, errMsg)
		return
	}
	defer resp.Body.Close()
	// Drain body to free connection.
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
	// v2.35.0: an explicit expected status replaces the heuristic entirely —
	// same rule as the App dot probe, and for the same reason: a host whose
	// health path deliberately answers something other than 2xx/401/403 is
	// "up" only if the operator said so.
	var ok bool
	if expectStatus > 0 {
		ok = resp.StatusCode == expectStatus
	} else {
		ok = resp.StatusCode < 400 || resp.StatusCode == 401 || resp.StatusCode == 403
	}
	_ = models.InsertProxyHealth(s.DB, hostID, ok, resp.StatusCode, latencyMs, "")
}

// StartUpstreamNotifier launches a goroutine that checks upstream health every 5 minutes.
func StartUpstreamNotifier(db *sql.DB) {
	upstreamNotifyState.prevFails = map[string]bool{}
	go func() {
		time.Sleep(20 * time.Second) // let the DB settle first
		for {
			runUpstreamCheck(db)
			time.Sleep(5 * time.Minute)
		}
	}()
}

func runUpstreamCheck(db *sql.DB) {
	// Skip entirely if neither SMTP nor webhook is configured.
	webhookURL, _ := models.GetSetting(db, settingNotifyWebhookURL)
	smtpHost, _ := models.GetSetting(db, settingSMTPHost)
	smtpTo, _ := models.GetSetting(db, settingSMTPTo)
	emailOK := smtpHost != "" && smtpTo != ""
	if webhookURL == "" && !emailOK {
		return
	}

	servers, err := models.ListCaddyServers(db)
	if err != nil {
		log.Printf("upstream-notifier: list servers: %v", err)
		return
	}

	upstreamNotifyState.mu.Lock()
	upstreamNotifyState.lastCheck = time.Now()
	// Prune recent alerts older than 7 days.
	fresh := upstreamNotifyState.recent[:0]
	for _, e := range upstreamNotifyState.recent {
		if time.Since(e.AlertedAt) < 7*24*time.Hour {
			fresh = append(fresh, e)
		}
	}
	upstreamNotifyState.recent = fresh
	upstreamNotifyState.mu.Unlock()

	for _, srv := range servers {
		upstreams := fetchCaddyUpstreams(srv.AdminURL)
		for addr, info := range upstreams {
			key := fmt.Sprintf("%d:%s", srv.ID, addr)
			nowFailing := info.Fails > 0

			upstreamNotifyState.mu.Lock()
			wasFailing := upstreamNotifyState.prevFails[key]
			upstreamNotifyState.prevFails[key] = nowFailing
			upstreamNotifyState.mu.Unlock()

			if nowFailing == wasFailing {
				continue // no state change
			}

			event := "recovered"
			if nowFailing {
				event = "down"
			}

			subject := fmt.Sprintf("[CaddyUI] Upstream %s %s on %s", addr, event, srv.Name)
			body := fmt.Sprintf(
				"CaddyUI upstream health alert\n\nServer : %s (%s)\nUpstream: %s\nEvent   : %s\nTime    : %s\n",
				srv.Name, srv.AdminURL, addr, event, time.Now().UTC().Format(time.RFC3339),
			)

			// v2.12.51: sendNotification fans out to webhook + ntfy + future
			// channels. Includes a "message" field so ntfy gets readable text.
			payload, _ := json.Marshal(map[string]any{
				"event":    "upstream_" + event,
				"message":  fmt.Sprintf("%s upstream %s on server %s", event, addr, srv.Name),
				"server":   srv.Name,
				"upstream": addr,
			})
			sendNotification(db, payload)
			// Send email.
			if emailOK {
				if err := sendEmail(db, subject, body); err != nil {
					log.Printf("upstream-notifier: send email: %v", err)
				}
			}

			upstreamNotifyState.mu.Lock()
			upstreamNotifyState.recent = append(upstreamNotifyState.recent, upstreamAlertEntry{
				ServerName: srv.Name,
				Upstream:   addr,
				Event:      event,
				AlertedAt:  time.Now(),
			})
			upstreamNotifyState.mu.Unlock()
			log.Printf("upstream-notifier: %s upstream %q on %q", event, addr, srv.Name)
		}
	}
}

// notifierState holds in-memory tracking for the cert-expiry notifier goroutine.
var notifierState struct {
	mu           sync.Mutex
	lastCheck    time.Time
	lastNotified []notifiedEntry
}

type notifiedEntry struct {
	Domain     string    `json:"domain"`
	DaysLeft   int       `json:"days_left"`
	NotifiedAt time.Time `json:"notified_at"`
}

// StartNotifier launches background goroutines:
//   - cert-expiry check every 24 h (webhook + email)
//   - upstream health check every 5 min (webhook + email)
func StartNotifier(db *sql.DB, _ *caddy.Client) {
	go func() {
		time.Sleep(10 * time.Second) // wait for DB to be ready
		for {
			runNotifierCheck(db)
			time.Sleep(24 * time.Hour)
		}
	}()
	StartUpstreamNotifier(db)
}

func runNotifierCheck(db *sql.DB) {
	notifierState.mu.Lock()
	notifierState.lastCheck = time.Now()
	notifierState.mu.Unlock()

	webhookURL, _ := models.GetSetting(db, settingNotifyWebhookURL)
	smtpHost, _ := models.GetSetting(db, settingSMTPHost)
	smtpTo, _ := models.GetSetting(db, settingSMTPTo)
	emailOK := smtpHost != "" && smtpTo != ""

	if webhookURL == "" && !emailOK {
		return // nothing configured
	}

	daysBeforeStr, _ := models.GetSetting(db, settingNotifyDaysBefore)
	daysBefore := defaultNotifyDaysBefore
	if d, err := strconv.Atoi(daysBeforeStr); err == nil && d > 0 {
		daysBefore = d
	}

	// Collect custom certs from all servers.
	servers, _ := models.ListCaddyServers(db)
	var certs []models.Certificate
	for _, srv := range servers {
		sc, err := models.ListCertificates(db, srv.ID)
		if err == nil {
			certs = append(certs, sc...)
		}
	}
	if len(servers) == 0 {
		// Fallback: server_id=1 if no servers table yet.
		certs, _ = models.ListCertificates(db, 1)
	}

	notifierState.mu.Lock()
	defer notifierState.mu.Unlock()

	// Prune stale notified entries (older than 24h) so we re-notify if still expiring.
	fresh := notifierState.lastNotified[:0]
	for _, e := range notifierState.lastNotified {
		if time.Since(e.NotifiedAt) < 24*time.Hour {
			fresh = append(fresh, e)
		}
	}
	notifierState.lastNotified = fresh

	alreadyNotified := map[string]struct{}{}
	for _, e := range notifierState.lastNotified {
		alreadyNotified[e.Domain] = struct{}{}
	}

	now := time.Now()
	threshold := time.Duration(daysBefore) * 24 * time.Hour

	for _, c := range certs {
		t := parsePEMExpiry(c.CertPEM)
		if t == nil || !t.After(now) {
			continue
		}
		remaining := t.Sub(now)
		if remaining > threshold {
			continue
		}
		domain := c.Name
		if _, seen := alreadyNotified[domain]; seen {
			continue
		}
		daysLeft := int(remaining.Hours() / 24)

		sent := false

		// v2.12.51: webhook + ntfy + future channels via sendNotification.
		// `sent` is still set so the email branch knows whether anything
		// went out (it tracks whether a dedup entry should be written).
		hasChannel := webhookURL != ""
		if u, _ := models.GetSetting(db, settingNotifyNtfyURL); u != "" {
			hasChannel = true
		}
		if hasChannel {
			payload, _ := json.Marshal(map[string]any{
				"event":     "cert_expiring",
				"message":   fmt.Sprintf("Certificate %s expires in %d days", domain, daysLeft),
				"domain":    domain,
				"days_left": daysLeft,
			})
			sendNotification(db, payload)
			sent = true
		}

		// Email notification.
		if emailOK {
			subject := fmt.Sprintf("[CaddyUI] Certificate expiring: %s (%d days left)", domain, daysLeft)
			body := fmt.Sprintf(
				"CaddyUI certificate expiry alert\n\nDomain  : %s\nDays left: %d\nExpires  : %s\n\nGo to /certificates to renew or replace it.\n",
				domain, daysLeft, t.UTC().Format("2006-01-02"),
			)
			if err := sendEmail(db, subject, body); err != nil {
				log.Printf("notifier: send email for %q: %v", domain, err)
			} else {
				sent = true
			}
		}

		if sent {
			notifierState.lastNotified = append(notifierState.lastNotified, notifiedEntry{
				Domain:     domain,
				DaysLeft:   daysLeft,
				NotifiedAt: now,
			})
			log.Printf("notifier: sent cert-expiry notification for %q (%d days left)", domain, daysLeft)
		}
	}

	// v2.11.14: also alert on Let's Encrypt / ACME-managed certs by doing a
	// live TLS dial against each enabled, SSL-enabled proxy host's first
	// domain. Caddy stores ACME-issued certs in its own data dir (which
	// CaddyUI can't read without sharing the volume), so dialing the
	// public endpoint and reading the peer certificate is the most
	// reliable way to surface expiry across LE / ZeroSSL / custom alike.
	runProxyHostCertExpiryCheck(db, webhookURL, emailOK, daysBefore, alreadyNotified, now)
}

// runProxyHostCertExpiryCheck — v2.11.14: extension of runNotifierCheck that
// covers Caddy-managed (ACME / LE) certificates. For each enabled proxy host
// with SSLEnabled=true, dials the first domain at :443, reads the leaf cert,
// and fires the same webhook + email channels when expiry is within
// daysBefore. Dedup keys are prefixed "proxy:" so they don't collide with
// the custom-cert dedup keys used above.
func runProxyHostCertExpiryCheck(db *sql.DB, webhookURL string, emailOK bool, daysBefore int, alreadyNotified map[string]struct{}, now time.Time) {
	threshold := time.Duration(daysBefore) * 24 * time.Hour
	servers, _ := models.ListCaddyServers(db)
	if len(servers) == 0 {
		// Fallback for fresh installs that haven't seeded servers yet.
		servers = []models.CaddyServer{{ID: 1}}
	}
	for _, srv := range servers {
		hosts, err := models.ListProxyHosts(db, srv.ID, 0, true, nil)
		if err != nil {
			continue
		}
		for _, h := range hosts {
			if !h.Enabled || !h.SSLEnabled {
				continue
			}
			first := strings.TrimSpace(strings.SplitN(h.Domains, ",", 2)[0])
			if first == "" || strings.HasPrefix(first, "*.") {
				// Skip wildcards — can't dial a wildcard hostname directly.
				continue
			}
			key := "proxy:" + first
			if _, seen := alreadyNotified[key]; seen {
				continue
			}
			d := &net.Dialer{Timeout: 8 * time.Second}
			conn, err := tls.DialWithDialer(d, "tcp", first+":443", &tls.Config{
				ServerName: first,
				// We need the peer cert chain, not auth — system roots are
				// fine. If verification fails we still get the chain back
				// via PeerCertificates so we can read expiry; but we keep
				// strict verify on first to surface expired/invalid certs.
			})
			if err != nil {
				continue
			}
			peers := conn.ConnectionState().PeerCertificates
			conn.Close()
			if len(peers) == 0 {
				continue
			}
			leaf := peers[0]
			if !leaf.NotAfter.After(now) {
				continue
			}
			remaining := leaf.NotAfter.Sub(now)
			if remaining > threshold {
				continue
			}
			daysLeft := int(remaining.Hours() / 24)

			sent := false
			hasChannel := webhookURL != ""
			if u, _ := models.GetSetting(db, settingNotifyNtfyURL); u != "" {
				hasChannel = true
			}
			if hasChannel {
				payload, _ := json.Marshal(map[string]any{
					"event":     "proxy_cert_expiring",
					"message":   fmt.Sprintf("Live cert for %s expires in %d days (issuer: %s)", first, daysLeft, leaf.Issuer.CommonName),
					"domain":    first,
					"days_left": daysLeft,
					"issuer":    leaf.Issuer.CommonName,
				})
				sendNotification(db, payload)
				sent = true
			}
			if emailOK {
				subject := fmt.Sprintf("[CaddyUI] Live cert expiring: %s (%d days left)", first, daysLeft)
				body := fmt.Sprintf(
					"CaddyUI live-cert expiry alert\n\nDomain  : %s\nDays left: %d\nExpires  : %s\nIssuer  : %s\n\nThis is the certificate Caddy is currently serving for the host. Check /certificates and the host's TLS settings if a renewal hasn't fired.\n",
					first, daysLeft, leaf.NotAfter.UTC().Format("2006-01-02"), leaf.Issuer.CommonName,
				)
				if err := sendEmail(db, subject, body); err != nil {
					log.Printf("notifier: send email for live cert %q: %v", first, err)
				} else {
					sent = true
				}
			}
			if sent {
				notifierState.lastNotified = append(notifierState.lastNotified, notifiedEntry{
					Domain:     key,
					DaysLeft:   daysLeft,
					NotifiedAt: now,
				})
				log.Printf("notifier: sent live-cert-expiry notification for %q (%d days left, issuer %q)", first, daysLeft, leaf.Issuer.CommonName)
			}
		}
	}
}

// sendNotification — v2.12.51: fan-out wrapper that dispatches a single
// notification event to every channel the user has configured. Replaces
// the bare `if whURL := models.GetSetting...; sendWebhookPayload(...)`
// pattern that was duplicated at every call site, and adds ntfy.sh
// alongside the existing generic-webhook path.
//
// The payload is the same canonical JSON every call site already builds:
//
//	{"event": "...", "message": "...", ...event-specific fields}
//
// For ntfy, "event" becomes the title and "message" becomes the body.
// For the generic webhook, the full JSON is POSTed verbatim (back-compat
// with anything users have wired up to it). Future channels (Telegram,
// Discord, Gotify) get added here.
//
// Each channel runs in its own goroutine so a slow / hung remote can't
// block the request handler — same fire-and-forget shape the old
// per-site `go func() { sendWebhookPayload(...) }()` pattern used.
func sendNotification(db *sql.DB, payload []byte) {
	// Extract title + body from the canonical payload shape for the
	// channels that need plain text (ntfy / future Telegram / Discord).
	var meta struct {
		Event   string `json:"event"`
		Message string `json:"message"`
	}
	_ = json.Unmarshal(payload, &meta)
	title := meta.Event
	if title == "" {
		title = "CaddyUI alert"
	}
	body := meta.Message
	if body == "" {
		// Fall back to the raw JSON if no message field — better than empty.
		body = string(payload)
	}

	if whURL, _ := models.GetSetting(db, settingNotifyWebhookURL); whURL != "" {
		go sendWebhookPayload(db, whURL, payload)
	}
	if ntfyURL, _ := models.GetSetting(db, settingNotifyNtfyURL); ntfyURL != "" {
		go sendNtfyMessage(db, ntfyURL, title, body)
	}
}

// sendNtfyMessage POSTs a notification to an ntfy.sh-compatible endpoint.
// Body goes as plain text; X-Title carries the event name. If a bearer
// token is configured (for self-hosted ntfy with auth, or ntfy.sh paid
// reserved topics), it's sent as Authorization: Bearer.
//
// ntfy.sh API: https://docs.ntfy.sh/publish/
func sendNtfyMessage(db *sql.DB, ntfyURL, title, body string) {
	req, err := http.NewRequest(http.MethodPost, ntfyURL, strings.NewReader(body))
	if err != nil {
		log.Printf("sendNtfy: create request: %v", err)
		return
	}
	req.Header.Set("Content-Type", "text/plain; charset=utf-8")
	if title != "" {
		req.Header.Set("X-Title", title)
	}
	// Tag CaddyUI events with a recognisable icon — ntfy renders X-Tags as
	// emoji shortcodes (https://docs.ntfy.sh/publish/#tags-emojis).
	req.Header.Set("X-Tags", "shield")
	if token, _ := models.GetSetting(db, settingNotifyNtfyToken); token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Printf("sendNtfy: POST %s: %v", logSafeURL(ntfyURL), unwrapURLError(err))
		return
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 400 {
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		log.Printf("sendNtfy: %s returned %d: %s", logSafeURL(ntfyURL), resp.StatusCode, strings.TrimSpace(string(raw)))
	}
}

// sendWebhookPayload POSTs a JSON payload to webhookURL. If a secret is configured
// in the DB (settingNotifyWebhookSecret), it adds an X-Signature-256 header
// containing the HMAC-SHA256 of the payload body in hex, prefixed "sha256=".
// The format is compatible with GitHub's webhook delivery signature.
func sendWebhookPayload(db *sql.DB, webhookURL string, payload []byte) {
	req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(payload))
	if err != nil {
		log.Printf("sendWebhook: create request: %v", err)
		return
	}
	req.Header.Set("Content-Type", "application/json")
	if secret, _ := models.GetSetting(db, settingNotifyWebhookSecret); secret != "" {
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(payload)
		req.Header.Set("X-Signature-256", "sha256="+hex.EncodeToString(mac.Sum(nil)))
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Printf("sendWebhook: POST %s: %v", logSafeURL(webhookURL), unwrapURLError(err))
		return
	}
	_ = resp.Body.Close()
}

func (s *Server) apiNotifierStatus(w http.ResponseWriter, r *http.Request) {
	webhookURL, _ := models.GetSetting(s.DB, settingNotifyWebhookURL)
	notifierState.mu.Lock()
	certLastCheck := notifierState.lastCheck
	certNotified := notifierState.lastNotified
	notifierState.mu.Unlock()

	upstreamNotifyState.mu.Lock()
	upLastCheck := upstreamNotifyState.lastCheck
	upRecent := upstreamNotifyState.recent
	upstreamNotifyState.mu.Unlock()

	status := map[string]any{
		"webhook_url":     webhookURL,
		"last_check":      certLastCheck,
		"last_notified":   certNotified,
		"upstream_check":  upLastCheck,
		"upstream_alerts": upRecent,
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(status)
}

func (s *Server) apiSystemStats(w http.ResponseWriter, r *http.Request) {
	// Admin-only. The dashboard template hides these cards for user/viewer
	// roles (see dashboard.html "System stats" block), but the endpoint is
	// still reachable via direct URL — so gate it here too. Host-level data
	// (uptime, RAM, load avg, Caddy-wide upstream totals) reflects the box
	// and Caddy as a whole, not anything a regular user owns, so exposing
	// it to non-admin roles leaks infrastructure details unnecessarily.
	cu := s.currentUser(r)
	if cu == nil || cu.Role != models.RoleAdmin {
		http.Error(w, "admin access required", http.StatusForbidden)
		return
	}

	stats := map[string]any{}

	// The first four values intentionally describe the machine/container that
	// runs CaddyUI. A remote Caddy admin API does not expose host load, total
	// memory, or host uptime. The response names both scopes so the Operations
	// page can distinguish these values from the selected-node Caddy telemetry.
	stats["host_scope"] = "caddyui"

	// Uptime from /proc/uptime (always the CaddyUI host machine).
	if data, err := os.ReadFile("/proc/uptime"); err == nil {
		fields := strings.Fields(string(data))
		if len(fields) > 0 {
			if secs, err := strconv.ParseFloat(fields[0], 64); err == nil {
				d := time.Duration(secs) * time.Second
				days := int(d.Hours()) / 24
				hours := int(d.Hours()) % 24
				mins := int(d.Minutes()) % 60
				stats["uptime"] = fmt.Sprintf("%dd %dh %dm", days, hours, mins)
			}
		}
	}

	// CPU load from /proc/loadavg.
	if data, err := os.ReadFile("/proc/loadavg"); err == nil {
		fields := strings.Fields(string(data))
		if len(fields) >= 3 {
			stats["load1"] = fields[0]
			stats["load5"] = fields[1]
			stats["load15"] = fields[2]
		}
	}

	// Memory from /proc/meminfo.
	if data, err := os.ReadFile("/proc/meminfo"); err == nil {
		memInfo := map[string]uint64{}
		for _, line := range strings.Split(string(data), "\n") {
			parts := strings.Fields(line)
			if len(parts) >= 2 {
				key := strings.TrimSuffix(parts[0], ":")
				if val, err := strconv.ParseUint(parts[1], 10, 64); err == nil {
					memInfo[key] = val
				}
			}
		}
		total := memInfo["MemTotal"]
		avail := memInfo["MemAvailable"]
		if total > 0 {
			used := total - avail
			stats["mem_total_mb"] = total / 1024
			stats["mem_used_mb"] = used / 1024
			stats["mem_pct"] = int(float64(used) / float64(total) * 100)
		}
	}

	// Per-server Caddy stats: active upstream requests + healthy upstream count.
	// The active-server cookie is authoritative, matching every other dashboard
	// value and preventing a stale or hand-edited query parameter from mixing
	// scopes inside one Operations page.
	sid := s.currentServerID(r)
	if srv, err := models.GetCaddyServer(s.DB, sid); err == nil {
		stats["selected_server_id"] = srv.ID
		stats["selected_server_name"] = srv.Name
		ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
		upstreams, upstreamErr := caddy.New(srv.AdminURL, srv.AdminUsername, srv.AdminPassword).GetUpstreamHealth(ctx)
		cancel()
		if upstreamErr != nil {
			stats["caddy_error"] = upstreamErr.Error()
		}
		activeReqs := 0
		healthy := 0
		for _, u := range upstreams {
			activeReqs += u.NumRequests
			if u.Healthy {
				healthy++
			}
		}
		if upstreamErr == nil {
			stats["active_requests"] = activeReqs
			stats["healthy_upstreams"] = healthy
			stats["total_upstreams"] = len(upstreams)
		}
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(stats)
}

// apiDashboardSparklines returns 7 days of daily totals for the dashboard
// stat card sparklines: views, visitors, bandwidth per day. Scoped to the
// active server's hostnames (same logic as dashboard handler). Returns JSON:
//
//	{ "days": [ { "date": "2026-05-25", "views": N, "visitors": N, "bandwidth": N }, … ] }
//
// v2.15.0
func (s *Server) apiDashboardSparklines(w http.ResponseWriter, r *http.Request) {
	cu := s.currentUser(r)
	if cu == nil {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	sid := s.currentServerID(r)
	isAdmin := cu.Role == models.RoleAdmin
	peers := s.groupPeerIDs(r)

	hosts, _ := models.ListProxyHosts(s.DB, sid, cu.ID, isAdmin, peers)
	redirs, _ := models.ListRedirectionHosts(s.DB, sid, cu.ID, isAdmin, peers)
	raws, _ := models.ListRawRoutes(s.DB, sid, cu.ID, isAdmin, peers)

	var hostsForActive []string
	for _, h := range hosts {
		hostsForActive = append(hostsForActive, h.DomainList()...)
	}
	for _, rh := range redirs {
		hostsForActive = append(hostsForActive, rh.DomainList()...)
	}
	for _, rr := range raws {
		hostsForActive = append(hostsForActive, rawRouteHosts(rr)...)
	}

	type DayPoint struct {
		Date      string `json:"date"`
		Views     int    `json:"views"`
		Visitors  int    `json:"visitors"`
		Bandwidth int64  `json:"bandwidth"`
	}

	now := time.Now().UTC()
	days := make([]DayPoint, 7)
	seenTrafficHosts := map[string]struct{}{}
	uniqueTrafficHosts := make([]string, 0, len(hostsForActive))
	for _, host := range hostsForActive {
		host = strings.TrimSpace(host)
		if host == "" {
			continue
		}
		hostKey := strings.ToLower(host)
		if _, seen := seenTrafficHosts[hostKey]; seen {
			continue
		}
		seenTrafficHosts[hostKey] = struct{}{}
		uniqueTrafficHosts = append(uniqueTrafficHosts, host)
	}

	for i := 6; i >= 0; i-- {
		day := now.AddDate(0, 0, -i).Truncate(24 * time.Hour)
		dayEnd := day.Add(24 * time.Hour)
		pt := DayPoint{Date: day.Format("2006-01-02")}
		for _, host := range uniqueTrafficHosts {
			if t, err := models.AccessTotalsBetween(s.DB, day, dayEnd, host, sid); err == nil {
				pt.Views += t.Views
				pt.Visitors += t.Visitors
			}
			if buckets, err := models.BandwidthBuckets(s.DB, day, dayEnd, 24*60*60, host, sid); err == nil {
				for _, bucket := range buckets {
					pt.Bandwidth += bucket.BytesOut
				}
			}
		}
		days[6-i] = pt
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{"days": days})
}

// apiCaddyVersion returns the running Caddy version from the selected fleet
// node's admin API. The former bootstrap-client lookup made every environment
// show the primary node's version.
func (s *Server) apiCaddyVersion(w http.ResponseWriter, r *http.Request) {
	sid := s.currentServerID(r)
	srv, serverErr := models.GetCaddyServer(s.DB, sid)
	w.Header().Set("Content-Type", "application/json")
	if serverErr != nil {
		json.NewEncoder(w).Encode(map[string]any{"version": "unknown", "error": serverErr.Error()})
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
	defer cancel()
	version, err := caddy.New(srv.AdminURL, srv.AdminUsername, srv.AdminPassword).GetVersionFromAdmin(ctx)
	if err != nil {
		if strings.TrimSpace(srv.Version) != "" {
			json.NewEncoder(w).Encode(map[string]any{
				"version": srv.Version, "server_id": srv.ID, "server_name": srv.Name, "source": "saved",
			})
			return
		}
		json.NewEncoder(w).Encode(map[string]any{
			"version": "unknown", "server_id": srv.ID, "server_name": srv.Name, "error": err.Error(),
		})
		return
	}
	_ = models.SetCaddyServerVersion(s.DB, sid, version)
	json.NewEncoder(w).Encode(map[string]any{
		"version": version, "server_id": srv.ID, "server_name": srv.Name,
	})
}

// apiVersionCheck returns the running version and the latest Docker Hub tag,
// so the UI can show an "update available" notice. Result is cached for 1 hour.
func (s *Server) apiVersionCheck(w http.ResponseWriter, r *http.Request) {
	s.versionMu.Lock()
	cached := s.latestVersion
	checkedAt := s.versionCheckedAt
	s.versionMu.Unlock()

	latest := cached
	if latest == "" || time.Since(checkedAt) > time.Hour {
		if v, err := fetchLatestDockerTag("applegater", "caddyui"); err == nil {
			latest = v
			s.versionMu.Lock()
			s.latestVersion = v
			s.versionCheckedAt = time.Now()
			s.versionMu.Unlock()
		}
	}

	current := s.Version
	hasUpdate := latest != "" && current != "" && current != "dev" && semverGT(latest, current)
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"current":    current,
		"latest":     latest,
		"has_update": hasUpdate,
	})
}

// fetchLatestDockerTag queries Docker Hub for the highest vX.Y.Z tag of image.
func fetchLatestDockerTag(namespace, image string) (string, error) {
	client := &http.Client{Timeout: 8 * time.Second}
	initialURL := fmt.Sprintf("https://hub.docker.com/v2/repositories/%s/%s/tags/?page_size=100", namespace, image)
	return fetchLatestDockerTagFrom(context.Background(), client, initialURL)
}

// fetchLatestDockerTagFrom follows Docker Hub's paginated tag response instead
// of assuming the newest semantic version is present on the first page.
func fetchLatestDockerTagFrom(ctx context.Context, client *http.Client, initialURL string) (string, error) {
	const maxPages = 100
	type tagPage struct {
		Next    string `json:"next"`
		Results []struct {
			Name string `json:"name"`
		} `json:"results"`
	}

	best := ""
	pageURL := initialURL
	for page := 0; pageURL != ""; page++ {
		if page >= maxPages {
			return "", fmt.Errorf("Docker Hub tag pagination exceeded %d pages", maxPages)
		}
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
		if err != nil {
			return "", err
		}
		resp, err := client.Do(req)
		if err != nil {
			return "", err
		}
		if resp.StatusCode != http.StatusOK {
			msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
			resp.Body.Close()
			return "", fmt.Errorf("Docker Hub tags returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(msg)))
		}
		var result tagPage
		err = json.NewDecoder(io.LimitReader(resp.Body, 256<<10)).Decode(&result)
		resp.Body.Close()
		if err != nil {
			return "", err
		}
		for _, tag := range result.Results {
			if semverValid(tag.Name) && (best == "" || semverGT(tag.Name, best)) {
				best = tag.Name
			}
		}
		pageURL = result.Next
	}
	if best == "" {
		return "", fmt.Errorf("no semver tags found")
	}
	return best, nil
}

// logSafeURL reduces a URL to scheme://host for logging. Webhook and ntfy URLs
// are bearer secrets (a Slack or Discord webhook URL IS the credential), and
// net/http errors repeat the full URL, so neither may be logged verbatim.
func logSafeURL(raw string) string {
	u, err := url.Parse(strings.TrimSpace(raw))
	if err != nil || u.Host == "" {
		return "(unparseable URL)"
	}
	return u.Scheme + "://" + u.Host + "/…"
}

// unwrapURLError drops the request URL that *url.Error carries, keeping only the
// underlying failure (dial error, timeout, ...).
func unwrapURLError(err error) error {
	var ue *url.Error
	if errors.As(err, &ue) {
		return ue.Err
	}
	return err
}