// 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) } }) } }