mirror of
https://github.com/Pacerino/CaddyProxyManager.git
synced 2026-07-24 22:43:44 -04:00
Introduce a Provider interface selectable via CPM_CADDY_MODE: - CaddyfileProvider renders per-host snippets (template whitespace fixed) - APIProvider manages Caddy's JSON config via the admin API Add configurable reload strategy (systemd/exec/api/none) and a configurable listen address for the bootstrapped server in API mode. Includes unit tests for the JSON builder, provider factory and the API provider (httptest).
165 lines
4.4 KiB
Go
165 lines
4.4 KiB
Go
package caddy
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Pacerino/CaddyProxyManager/internal/database"
|
|
)
|
|
|
|
// fakeCaddy is a minimal in-memory stand-in for the Caddy admin API that
|
|
// mimics the quirks the real API exhibits (200 + "null" body for absent paths).
|
|
type fakeCaddy struct {
|
|
serverExists bool
|
|
routes map[string]bool // route id -> exists
|
|
calls []string // method+path log for assertions
|
|
}
|
|
|
|
func newFakeCaddy() *fakeCaddy {
|
|
return &fakeCaddy{routes: map[string]bool{}}
|
|
}
|
|
|
|
func (f *fakeCaddy) handler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.calls = append(f.calls, r.Method+" "+r.URL.Path)
|
|
path := r.URL.Path
|
|
|
|
switch {
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/apps/http/servers/"):
|
|
// Caddy returns 200 + "null" when the server is absent.
|
|
if f.serverExists {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"listen":[":8080"],"routes":[]}`))
|
|
} else {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("null"))
|
|
}
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/id/"):
|
|
id := strings.TrimPrefix(path, "/id/")
|
|
w.WriteHeader(http.StatusOK)
|
|
if f.routes[id] {
|
|
w.Write([]byte(`{"@id":"` + id + `"}`))
|
|
} else {
|
|
w.Write([]byte("null"))
|
|
}
|
|
case r.Method == http.MethodPatch && path == "/config/apps/http":
|
|
f.serverExists = true
|
|
w.WriteHeader(http.StatusOK)
|
|
case r.Method == http.MethodPost && strings.HasSuffix(path, "/routes"):
|
|
// New route appended; mark generic existence is handled by id GET.
|
|
w.WriteHeader(http.StatusOK)
|
|
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/id/"):
|
|
w.WriteHeader(http.StatusOK)
|
|
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/id/"):
|
|
id := strings.TrimPrefix(path, "/id/")
|
|
delete(f.routes, id)
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}
|
|
})
|
|
}
|
|
|
|
func newTestProvider(url string) *APIProvider {
|
|
return &APIProvider{
|
|
baseURL: strings.TrimRight(url, "/"),
|
|
serverName: "srv0",
|
|
listen: []string{":8080"},
|
|
client: &http.Client{Timeout: 2 * time.Second},
|
|
}
|
|
}
|
|
|
|
func sampleHost() database.Host {
|
|
h := database.Host{
|
|
Domains: "example.com",
|
|
Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}},
|
|
}
|
|
h.ID = 1
|
|
return h
|
|
}
|
|
|
|
func TestAPIProviderBootstrapsServerOnFirstWrite(t *testing.T) {
|
|
fake := newFakeCaddy()
|
|
srv := httptest.NewServer(fake.handler())
|
|
defer srv.Close()
|
|
|
|
p := newTestProvider(srv.URL)
|
|
if err := p.WriteHost(sampleHost()); err != nil {
|
|
t.Fatalf("WriteHost: %v", err)
|
|
}
|
|
|
|
if !fake.serverExists {
|
|
t.Fatal("expected server to be bootstrapped via PATCH")
|
|
}
|
|
assertCalled(t, fake, "PATCH /config/apps/http")
|
|
assertCalled(t, fake, "POST /config/apps/http/servers/srv0/routes")
|
|
}
|
|
|
|
func TestAPIProviderUpdatesExistingRoute(t *testing.T) {
|
|
fake := newFakeCaddy()
|
|
fake.serverExists = true
|
|
fake.routes["cpm_host_1"] = true
|
|
srv := httptest.NewServer(fake.handler())
|
|
defer srv.Close()
|
|
|
|
p := newTestProvider(srv.URL)
|
|
if err := p.WriteHost(sampleHost()); err != nil {
|
|
t.Fatalf("WriteHost: %v", err)
|
|
}
|
|
|
|
// Existing route -> PATCH /id/, not a POST.
|
|
assertCalled(t, fake, "PATCH /id/cpm_host_1")
|
|
for _, c := range fake.calls {
|
|
if strings.HasPrefix(c, "POST ") {
|
|
t.Errorf("did not expect POST on update, got %q", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPIProviderRemoveHost(t *testing.T) {
|
|
fake := newFakeCaddy()
|
|
fake.serverExists = true
|
|
fake.routes["cpm_host_1"] = true
|
|
srv := httptest.NewServer(fake.handler())
|
|
defer srv.Close()
|
|
|
|
p := newTestProvider(srv.URL)
|
|
if err := p.RemoveHost(1); err != nil {
|
|
t.Fatalf("RemoveHost: %v", err)
|
|
}
|
|
assertCalled(t, fake, "DELETE /id/cpm_host_1")
|
|
if fake.routes["cpm_host_1"] {
|
|
t.Error("route should have been deleted")
|
|
}
|
|
}
|
|
|
|
func TestAPIProviderRemoveMissingHostIsNoop(t *testing.T) {
|
|
fake := newFakeCaddy()
|
|
fake.serverExists = true
|
|
srv := httptest.NewServer(fake.handler())
|
|
defer srv.Close()
|
|
|
|
p := newTestProvider(srv.URL)
|
|
if err := p.RemoveHost(99); err != nil {
|
|
t.Fatalf("RemoveHost on missing should be noop, got %v", err)
|
|
}
|
|
for _, c := range fake.calls {
|
|
if strings.HasPrefix(c, "DELETE ") {
|
|
t.Errorf("expected no DELETE for missing host, got %q", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertCalled(t *testing.T, f *fakeCaddy, want string) {
|
|
t.Helper()
|
|
for _, c := range f.calls {
|
|
if c == want {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("expected call %q, calls were: %v", want, f.calls)
|
|
}
|