internal/server/public_health_monitor_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"sync/atomic"
"testing"
"time"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
)
// v2.35.0 (issue #39 follow-up): the "Public" health checker — the persisted,
// 24h-history probe behind /proxy-hosts/{id}/health and the left-most dot on
// the proxy hosts list — was a *third* CaddyUI-run probe alongside the App and
// Port dots v2.28.0 made configurable. It was never wired to MonitorMode: a
// host set to "Off" (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 "/" with the default classification.
// These tests pin the fix.
// newPublicHealthTestServer builds a Server plus a registered CaddyServer
// (serverID 1). checkAllProxyHosts walks models.ListCaddyServers before it
// ever looks at a host, so a test that skips this and creates hosts under a
// bare serverID with no matching caddy_servers row silently checks nothing —
// the loop body never runs. That previously hung a test outright: it blocked
// forever reading from an httptest handler's channel that was never fed
// because checkAllProxyHosts had zero servers to iterate.
func newPublicHealthTestServer(t *testing.T) *Server {
t.Helper()
conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
if _, err := models.CreateCaddyServer(conn, &models.CaddyServer{
Name: "Primary", AdminURL: "http://primary:2019", Type: models.CaddyServerTypeManaged,
}); err != nil {
t.Fatal(err)
}
return &Server{DB: conn}
}
func hostportOfURL(t *testing.T, raw string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse test server URL %q: %v", raw, err)
}
return u.Host
}
func TestCheckProxyHostClosesIdleConnections(t *testing.T) {
for _, status := range []int{http.StatusOK, http.StatusFound, http.StatusNoContent} {
t.Run(http.StatusText(status), func(t *testing.T) {
s := newPublicHealthTestServer(t)
const probes = 3
closed := make(chan struct{}, probes)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status == http.StatusFound {
w.Header().Set("Location", "/login")
}
w.WriteHeader(status)
if status != http.StatusNoContent {
_, _ = w.Write([]byte("healthy"))
}
}))
// No server-side idle timeout: the probe must close its own connection.
srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
if state == http.StateClosed {
closed <- struct{}{}
}
}
srv.StartTLS()
defer srv.Close()
hostID, err := models.CreateProxyHost(s.DB, 1, 0, &models.ProxyHost{
Domains: hostportOfURL(t, srv.URL), ForwardScheme: "https",
ForwardHost: "backend", ForwardPort: 443, Enabled: true, SSLEnabled: true,
})
if err != nil {
t.Fatal(err)
}
for i := 0; i < probes; i++ {
s.checkProxyHost(hostID, srv.URL, http.MethodGet, 0, 5*time.Second)
select {
case <-closed:
case <-time.After(2 * time.Second):
t.Fatalf("probe %d left its connection open", i+1)
}
}
history, err := models.GetProxyHealthHistory(s.DB, hostID, probes)
if err != nil {
t.Fatal(err)
}
if len(history) != probes {
t.Fatalf("health history has %d entries, want %d", len(history), probes)
}
for _, check := range history {
if !check.OK || check.StatusCode != status {
t.Errorf("health check = %+v, want OK with HTTP %d", check, status)
}
}
})
}
}
func TestCheckAllProxyHostsSkipsMonitoringOff(t *testing.T) {
s := newPublicHealthTestServer(t)
var hits int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
host := &models.ProxyHost{
Domains: hostportOfURL(t, srv.URL), ForwardScheme: "http", ForwardHost: "backend",
ForwardPort: 80, Enabled: true, SSLEnabled: false, MonitorMode: "off",
}
id, err := models.CreateProxyHost(s.DB, 1, 0, host)
if err != nil {
t.Fatal(err)
}
s.checkAllProxyHosts()
if n := atomic.LoadInt64(&hits); n != 0 {
t.Errorf("probe made %d request(s) to a monitoring-off host, want 0", n)
}
history, err := models.GetProxyHealthHistory(s.DB, id, 10)
if err != nil {
t.Fatal(err)
}
if len(history) != 0 {
t.Errorf("monitoring-off host has %d persisted health row(s), want 0", len(history))
}
}
func TestCheckAllProxyHostsUsesCustomPathMethodAndExpectStatus(t *testing.T) {
s := newPublicHealthTestServer(t)
gotPath := make(chan string, 1)
gotMethod := make(chan string, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath <- r.URL.Path
gotMethod <- r.Method
w.WriteHeader(http.StatusNoContent) // 204: "up" only because expectStatus says so
}))
defer srv.Close()
host := &models.ProxyHost{
Domains: hostportOfURL(t, srv.URL), ForwardScheme: "http", ForwardHost: "backend",
ForwardPort: 80, Enabled: true, SSLEnabled: false,
MonitorMode: "custom", MonitorPath: "/healthz", MonitorMethod: "HEAD", MonitorExpectStatus: 204,
}
id, err := models.CreateProxyHost(s.DB, 1, 0, host)
if err != nil {
t.Fatal(err)
}
s.checkAllProxyHosts()
if p := <-gotPath; p != "/healthz" {
t.Errorf("probed path = %q, want /healthz", p)
}
if m := <-gotMethod; m != "HEAD" {
t.Errorf("probed method = %q, want HEAD", m)
}
history, err := models.GetProxyHealthHistory(s.DB, id, 1)
if err != nil {
t.Fatal(err)
}
if len(history) != 1 {
t.Fatalf("history rows = %d, want 1", len(history))
}
if !history[0].OK {
t.Error("204 with expectStatus=204 should record OK, got not-OK")
}
if history[0].StatusCode != 204 {
t.Errorf("recorded status = %d, want 204", history[0].StatusCode)
}
}
// v2.36.0 (issue #59): the same POST-only scenario for the persisted Public
// check. The stored method is lowercase on purpose — rows written by hand or
// by an older build must still resolve, since only the form parser canonicalises.
func TestCheckAllProxyHostsReachesPostOnlyRouteWithPost(t *testing.T) {
s := newPublicHealthTestServer(t)
gotMethod := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod <- r.Method
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
host := &models.ProxyHost{
Domains: hostportOfURL(t, srv.URL), ForwardScheme: "http", ForwardHost: "backend",
ForwardPort: 80, Enabled: true, SSLEnabled: false,
MonitorMode: "custom", MonitorPath: "/api/ping", MonitorMethod: "post",
}
id, err := models.CreateProxyHost(s.DB, 1, 0, host)
if err != nil {
t.Fatal(err)
}
s.checkAllProxyHosts()
if m := <-gotMethod; m != "POST" {
t.Errorf("probed method = %q, want POST", m)
}
history, err := models.GetProxyHealthHistory(s.DB, id, 1)
if err != nil {
t.Fatal(err)
}
if len(history) != 1 || !history[0].OK || history[0].StatusCode != 200 {
t.Fatalf("history = %+v, want one OK row with HTTP 200 — the POST probe must reach the upstream", history)
}
}
// The pre-existing failure mode: a custom health path answering something
// other than the default-accepted codes (2xx/3xx/401/403) must show as down
// when no expectStatus is set, matching the App-dot semantics exactly.
func TestCheckAllProxyHostsWithoutExpectStatusUsesDefaultHeuristic(t *testing.T) {
s := newPublicHealthTestServer(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot) // 418: outside the default-accepted set
}))
defer srv.Close()
host := &models.ProxyHost{
Domains: hostportOfURL(t, srv.URL), ForwardScheme: "http", ForwardHost: "backend",
ForwardPort: 80, Enabled: true, SSLEnabled: false,
}
id, err := models.CreateProxyHost(s.DB, 1, 0, host)
if err != nil {
t.Fatal(err)
}
s.checkAllProxyHosts()
history, err := models.GetProxyHealthHistory(s.DB, id, 1)
if err != nil {
t.Fatal(err)
}
if len(history) != 1 {
t.Fatalf("history rows = %d, want 1", len(history))
}
if history[0].OK {
t.Error("418 with no expectStatus should record not-OK")
}
}
func TestCheckAllProxyHostsStillChecksAutomaticHosts(t *testing.T) {
s := newPublicHealthTestServer(t)
var hits int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
host := &models.ProxyHost{
Domains: hostportOfURL(t, srv.URL), ForwardScheme: "http", ForwardHost: "backend",
ForwardPort: 80, Enabled: true, SSLEnabled: false, // MonitorMode left unset — must behave as before this fix
}
if _, err := models.CreateProxyHost(s.DB, 1, 0, host); err != nil {
t.Fatal(err)
}
s.checkAllProxyHosts()
if n := atomic.LoadInt64(&hits); n != 1 {
t.Errorf("automatic-mode host got %d probe(s), want 1 — the flag is opt-in and must not change default behaviour", n)
}
}
func TestPublicHealthDueRespectsCustomInterval(t *testing.T) {
s := newPublicHealthTestServer(t)
host := &models.ProxyHost{
Domains: "app.example.com", ForwardScheme: "http", ForwardHost: "backend",
ForwardPort: 80, Enabled: true,
}
id, err := models.CreateProxyHost(s.DB, 1, 0, host)
if err != nil {
t.Fatal(err)
}
// No history yet: always due, regardless of interval.
if !s.publicHealthDue(id, 30*time.Minute) {
t.Error("never-checked host should be due")
}
// At or below the native 5-minute tick: always due (can't run faster than
// the ticker itself).
if !s.publicHealthDue(id, publicHealthCheckerInterval) {
t.Error("a host at the native interval should always be due")
}
if err := models.InsertProxyHealth(s.DB, id, true, 200, 10, ""); err != nil {
t.Fatal(err)
}
if s.publicHealthDue(id, 30*time.Minute) {
t.Error("a host just checked with a 30-minute interval should not be due yet")
}
}
func TestPublicHealthCadenceLabel(t *testing.T) {
cases := []struct {
name string
h models.ProxyHost
want string
}{
{"auto", models.ProxyHost{MonitorMode: "auto"}, "every 5 min"},
{"blank", models.ProxyHost{}, "every 5 min"},
{"off", models.ProxyHost{MonitorMode: "off"}, "every 5 min"},
{"custom no interval", models.ProxyHost{MonitorMode: "custom"}, "every 5 min"},
{"custom below native tick", models.ProxyHost{MonitorMode: "custom", MonitorIntervalSec: 60}, "every 5 min"},
{"custom 30 min", models.ProxyHost{MonitorMode: "custom", MonitorIntervalSec: 1800}, "roughly every 30 min (custom)"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := publicHealthCadenceLabel(c.h); got != c.want {
t.Errorf("publicHealthCadenceLabel(%+v) = %q, want %q", c.h, got, c.want)
}
})
}
}