diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7581f8d..33af40a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: run: go test -count=1 ./... test-frontend: - name: Build frontend + name: Test & build frontend runs-on: self-hosted defaults: run: @@ -59,6 +59,8 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install run: npm ci || npm install + - name: Test + run: npm run test - name: Type-check and build run: | mkdir -p ../backend/embed/assets @@ -123,11 +125,16 @@ jobs: release: name: Build binaries & release runs-on: self-hosted + timeout-minutes: 30 needs: [test-backend, test-frontend] # Only build downloadable binaries and a release on version tags. if: startsWith(github.ref, 'refs/tags/v') env: CGO_ENABLED: "0" + # Limit compiler parallelism to keep peak memory down on the self-hosted + # runner (exit 143 / SIGTERM was an OOM kill during cross-compilation). + GOFLAGS: "-p=2" + GOMAXPROCS: "2" steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pr-e2e.yml b/.github/workflows/pr-e2e.yml new file mode 100644 index 0000000..8a9a4ce --- /dev/null +++ b/.github/workflows/pr-e2e.yml @@ -0,0 +1,145 @@ +name: PR E2E + +# Deploys the PR's image to the self-hosted runner and runs a full end-to-end +# test against it in API mode. The environment is left running until the PR is +# closed (or torn down early via a "/teardown" comment) so it can be inspected +# through a Cloudflare Tunnel. + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + issue_comment: + types: [created] + +# Only ever run one PR environment at a time (fixed port 3001). +concurrency: + group: pr-e2e + cancel-in-progress: false + +env: + COMPOSE_FILE: docker-compose.e2e.yml + CPM_PORT: "3001" + +jobs: + deploy-e2e: + name: Deploy & E2E + runs-on: self-hosted + # Own PRs only (not forks), and not on the closed event. + if: > + github.event_name == 'pull_request' && + github.event.action != 'closed' && + github.event.pull_request.head.repo.full_name == github.repository + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + CPM_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL || 'admin@example.com' }} + CPM_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD || 'changeme' }} + steps: + - uses: actions/checkout@v4 + + # Fail fast if a *different* PR's stack already holds the fixed port. + - name: Ensure port 3001 is free for this PR + run: | + set -euo pipefail + project="cpm-pr-${PR_NUMBER}" + # Any container publishing :3001 that is NOT this PR's stack blocks us. + holder=$(docker ps --filter "publish=${CPM_PORT}" --format '{{.Names}}' | grep -v "^cpm-pr-${PR_NUMBER}" || true) + if [ -n "$holder" ]; then + echo "::error::Port ${CPM_PORT} is in use by another PR environment: $holder" + echo "Tear it down first (close that PR or comment /teardown there)." + exit 1 + fi + echo "Port ${CPM_PORT} is available for ${project}." + + - name: Build PR image + run: | + set -euo pipefail + IMAGE="cpm-pr-${PR_NUMBER}:${GITHUB_SHA::8}" + docker build \ + --build-arg VERSION="pr-${PR_NUMBER}" \ + --build-arg COMMIT="${GITHUB_SHA::8}" \ + -t "$IMAGE" . + echo "CPM_IMAGE=$IMAGE" >> "$GITHUB_ENV" + + - name: Bring up the environment + run: | + set -euo pipefail + # Recreate cleanly (handles the synchronize re-run case). + docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" up -d --force-recreate + + - name: Run E2E tests + env: + CPM_URL: http://localhost:3001 + CADDY_URL: http://localhost:8080 + ADMIN_EMAIL: ${{ env.CPM_ADMIN_EMAIL }} + ADMIN_PASSWORD: ${{ env.CPM_ADMIN_PASSWORD }} + TEST_DOMAIN: app.e2e.local + run: bash test/e2e/run.sh + + - name: Dump logs on failure + if: failure() + run: docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" logs --no-color || true + + - name: Comment environment URL + if: success() + uses: actions/github-script@v7 + with: + script: | + const n = context.payload.pull_request.number; + const marker = ""; + const body = `${marker}\n✅ **PR E2E environment is up** (port \`3001\`).\n\n` + + `Point your Cloudflare Tunnel at \`localhost:3001\` to inspect it.\n\n` + + `Comment \`/teardown\` to stop it early; it is otherwise removed when the PR closes.`; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: n, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner: context.repo.owner, repo: context.repo.repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: n, body }); + } + + teardown-on-close: + name: Teardown (PR closed) + runs-on: self-hosted + if: > + github.event_name == 'pull_request' && + github.event.action == 'closed' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - uses: actions/checkout@v4 + - name: Tear down the environment + run: | + set -euo pipefail + docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" down -v --remove-orphans || true + docker image rm "cpm-pr-${PR_NUMBER}:${GITHUB_SHA::8}" 2>/dev/null || true + echo "Environment for PR #${PR_NUMBER} torn down." + + teardown-on-comment: + name: Teardown (/teardown comment) + runs-on: self-hosted + # Only on PR comments containing "/teardown", authored by the repo owner. + if: > + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + contains(github.event.comment.body, '/teardown') && + github.event.comment.user.login == github.repository_owner + env: + PR_NUMBER: ${{ github.event.issue.number }} + steps: + - uses: actions/checkout@v4 + - name: Tear down the environment + run: | + set -euo pipefail + docker compose -p "cpm-pr-${PR_NUMBER}" -f "$COMPOSE_FILE" down -v --remove-orphans || true + echo "Environment for PR #${PR_NUMBER} torn down." + - name: Acknowledge + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: ${{ env.PR_NUMBER }}, + body: "🧹 PR E2E environment torn down.", + }); diff --git a/Dockerfile b/Dockerfile index 05aa946..6458bc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,18 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ # ---- Runtime ---- FROM alpine:3.21 -RUN apk add --no-cache ca-certificates caddy +# Install Caddy from the official static release rather than the Alpine package, +# which can lag behind (older builds lack `list-modules --json`, used by CPM to +# detect plugins). Pin the version here; TARGETARCH is provided by buildx. +ARG CADDY_VERSION=2.11.4 +ARG TARGETARCH=amd64 +RUN apk add --no-cache ca-certificates \ + && wget -qO /tmp/caddy.tar.gz \ + "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_${TARGETARCH}.tar.gz" \ + && tar -xzf /tmp/caddy.tar.gz -C /usr/local/bin caddy \ + && rm /tmp/caddy.tar.gz \ + && chmod +x /usr/local/bin/caddy \ + && caddy version COPY --from=backend /out/cpm /usr/local/bin/cpm COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/backend/embed/caddy/host.hbs b/backend/embed/caddy/host.hbs index ea1e932..af881ae 100644 --- a/backend/embed/caddy/host.hbs +++ b/backend/embed/caddy/host.hbs @@ -1,4 +1,11 @@ {{host.domains}} { +{{#if BasicAuth}} + basic_auth { +{{#each BasicAuth}} + {{User}} {{Hash}} +{{/each}} + } +{{/if}} {{#if host.upstreams}} {{#if host.matcher}} reverse_proxy {{host.matcher}}{{#each host.upstreams}} {{backend}}{{/each}} diff --git a/backend/internal/api/handler/auth_test.go b/backend/internal/api/handler/auth_test.go new file mode 100644 index 0000000..edab2da --- /dev/null +++ b/backend/internal/api/handler/auth_test.go @@ -0,0 +1,80 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/config" +) + +func setAuthMode(t *testing.T, mode string) { + t.Helper() + prev := config.Configuration.Auth.Mode + config.Configuration.Auth.Mode = mode + t.Cleanup(func() { config.Configuration.Auth.Mode = prev }) +} + +func TestAuthConfigLocal(t *testing.T) { + h := newTestHandler(t) + setAuthMode(t, "local") + rec := doRequest(h.AuthConfig(), http.MethodGet, "/auth/config", nil, nil) + var resp struct { + Mode string `json:"mode"` + } + decodeResult(t, rec, &resp) + if resp.Mode != "local" { + t.Errorf("mode = %q, want local", resp.Mode) + } +} + +func TestAuthConfigOIDC(t *testing.T) { + h := newTestHandler(t) + setAuthMode(t, "oidc") + rec := doRequest(h.AuthConfig(), http.MethodGet, "/auth/config", nil, nil) + var resp struct { + Mode string `json:"mode"` + } + decodeResult(t, rec, &resp) + if resp.Mode != "oidc" { + t.Errorf("mode = %q, want oidc", resp.Mode) + } +} + +func TestUserLoginDisabledUnderOIDC(t *testing.T) { + h := newTestHandler(t) + setAuthMode(t, "oidc") + body := map[string]any{"Email": "a@b.com", "Secret": "x"} + rec := doRequest(h.UserLogin(), http.MethodPost, "/users/login", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when oidc enabled, got %d", rec.Code) + } +} + +func TestUserLoginValidationFails(t *testing.T) { + h := newTestHandler(t) + setAuthMode(t, "local") + // Invalid email + missing secret. + body := map[string]any{"Email": "notanemail"} + rec := doRequest(h.UserLogin(), http.MethodPost, "/users/login", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestOIDCLoginDisabled(t *testing.T) { + h := newTestHandler(t) + setAuthMode(t, "local") + rec := doRequest(h.OIDCLogin(), http.MethodGet, "/auth/oidc/login", nil, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when oidc disabled, got %d", rec.Code) + } +} + +func TestOIDCCallbackDisabled(t *testing.T) { + h := newTestHandler(t) + setAuthMode(t, "local") + rec := doRequest(h.OIDCCallback(), http.MethodGet, "/auth/oidc/callback", nil, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when oidc disabled, got %d", rec.Code) + } +} diff --git a/backend/internal/api/handler/caddy.go b/backend/internal/api/handler/caddy.go new file mode 100644 index 0000000..0b221ec --- /dev/null +++ b/backend/internal/api/handler/caddy.go @@ -0,0 +1,236 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + h "github.com/Pacerino/CaddyProxyManager/internal/api/http" + "github.com/Pacerino/CaddyProxyManager/internal/caddy" + "github.com/Pacerino/CaddyProxyManager/internal/caddy/schema" + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" + "github.com/Pacerino/CaddyProxyManager/internal/jobqueue" + + "github.com/go-chi/chi/v5" +) + +// modulesResponse is the payload returned by GetCaddyModules. +type modulesResponse struct { + // Modules is the flat, sorted list of every module the build reports. + Modules []caddy.Module `json:"modules"` + // Plugins is the subset that is not part of stock Caddy, i.e. the + // user-supplied plugins this build was compiled with. + Plugins []caddy.Module `json:"plugins"` +} + +// GetCaddyModules returns the modules reported by the configured Caddy binary +// via `caddy list-modules --json`. +// Route: GET /caddy/modules +func (s Handler) GetCaddyModules() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + modules, err := caddy.ListModules() + if err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + plugins := make([]caddy.Module, 0) + for _, m := range modules { + if !m.Standard { + plugins = append(plugins, m) + } + } + + h.ResultResponseJSON(w, r, http.StatusOK, modulesResponse{ + Modules: modules, + Plugins: plugins, + }) + } +} + +// GetCaddyConfig returns the live Caddy configuration read through the admin +// API. Only available in API mode. An optional ?path= query selects a subtree. +// Route: GET /caddy/config +func (s Handler) GetCaddyConfig() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider, ok := apiProviderOrError(w, r) + if !ok { + return + } + + var raw json.RawMessage + if err := provider.GetConfig(r.URL.Query().Get("path"), &raw); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadGateway, err.Error(), nil) + return + } + if len(raw) == 0 { + raw = json.RawMessage("null") + } + h.ResultResponseJSON(w, r, http.StatusOK, raw) + } +} + +// GetCaddySchemas returns the global-scope typed configuration schemas. +// Modules without a schema are configured through the raw JSON fallback. +// Route: GET /caddy/schemas +func (s Handler) GetCaddySchemas() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + h.ResultResponseJSON(w, r, http.StatusOK, schema.WithScope(schema.ScopeGlobal)) + } +} + +// GetModuleConfigs lists stored module configurations. +// Route: GET /caddy/module-configs +func (s Handler) GetModuleConfigs() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + var configs []database.ModuleConfig + if err := s.DB.Find(&configs).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + h.ResultResponseJSON(w, r, http.StatusOK, configs) + } +} + +// moduleConfigRequest is the body accepted by SetModuleConfig. Either Config +// (raw JSON fallback) or Values (typed form for a known schema) must be set. +type moduleConfigRequest struct { + ModuleID string `json:"moduleId"` + Path string `json:"path,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Config json.RawMessage `json:"config,omitempty"` + Values map[string]any `json:"values,omitempty"` +} + +// SetModuleConfig creates or updates the configuration for a module and +// applies it to the live Caddy config. When a schema is registered for the +// module, Values are validated and rendered; otherwise raw Config is used. +// Route: PUT /caddy/module-configs +func (s Handler) SetModuleConfig() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider, ok := apiProviderOrError(w, r) + if !ok { + return + } + + var req moduleConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + if req.ModuleID == "" { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "moduleId is required", nil) + return + } + + path := req.Path + var rawConfig json.RawMessage + + if sc, found := schema.Get(req.ModuleID); found { + // Typed form: validate + render through the schema. + built, err := sc.Build(req.Values) + if err != nil { + if ve, isVE := err.(*schema.ValidationError); isVE { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "schema validation failed", ve.Fields) + return + } + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + rawConfig = built + if path == "" { + path = sc.Path + } + } else { + // Raw JSON fallback. + if len(req.Config) == 0 { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "config is required for modules without a schema", nil) + return + } + if !json.Valid(req.Config) { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "config is not valid JSON", nil) + return + } + rawConfig = req.Config + } + + if path == "" { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "path is required", nil) + return + } + + mc := database.ModuleConfig{ + ModuleID: req.ModuleID, + Path: path, + Config: database.JSON(rawConfig), + Enabled: true, + } + if req.Enabled != nil { + mc.Enabled = *req.Enabled + } + + // Upsert by ModuleID. + var existing database.ModuleConfig + if err := s.DB.Where("module_id = ?", req.ModuleID).First(&existing).Error; err == nil { + mc.ID = existing.ID + } + if err := s.DB.Save(&mc).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyApplyModuleConfig", + Action: func() error { return provider.ApplyModuleConfig(mc) }, + }); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, mc) + } +} + +// DeleteModuleConfig removes a module configuration and its live config. +// Route: DELETE /caddy/module-configs/{id} +func (s Handler) DeleteModuleConfig() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + provider, ok := apiProviderOrError(w, r) + if !ok { + return + } + id := chi.URLParam(r, "id") + + var mc database.ModuleConfig + if err := s.DB.Where("id = ?", id).First(&mc).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyRemoveModuleConfig", + Action: func() error { return provider.RemoveModuleConfig(mc) }, + }); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.DB.Delete(&database.ModuleConfig{}, mc.ID).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + h.ResultResponseJSON(w, r, http.StatusOK, true) + } +} + +// apiProviderOrError resolves the API provider or writes an error response and +// returns false. Module configuration is only supported in API mode for now. +func apiProviderOrError(w http.ResponseWriter, r *http.Request) (*caddy.APIProvider, bool) { + if strings.ToLower(config.Configuration.Caddy.Mode) != "api" { + h.ResultErrorJSON(w, r, http.StatusBadRequest, + "caddy configuration via the admin API is only available when CPM_CADDY_MODE=api", nil) + return nil, false + } + return caddy.NewAPIProvider(), true +} diff --git a/backend/internal/api/handler/caddy_endpoints_test.go b/backend/internal/api/handler/caddy_endpoints_test.go new file mode 100644 index 0000000..1480362 --- /dev/null +++ b/backend/internal/api/handler/caddy_endpoints_test.go @@ -0,0 +1,164 @@ +package handler + +import ( + "net/http" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/caddy/schema" + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +func TestGetCaddySchemas(t *testing.T) { + h := newTestHandler(t) + rec := doRequest(h.GetCaddySchemas(), http.MethodGet, "/caddy/schemas", nil, nil) + var schemas []schema.Schema + decodeResult(t, rec, &schemas) + // Only global-scope schemas (cloudflare) should appear. + for _, s := range schemas { + found := false + for _, sc := range s.Scopes { + if sc == schema.ScopeGlobal { + found = true + } + } + if !found { + t.Errorf("schema %s is not global-scoped", s.ModuleID) + } + } +} + +func TestGetHostScopedSchemas(t *testing.T) { + h := newTestHandler(t) + rec := doRequest(h.GetHostScopedSchemas(), http.MethodGet, "/caddy/host-schemas", nil, nil) + var schemas []schema.Schema + decodeResult(t, rec, &schemas) + if len(schemas) == 0 { + t.Fatal("expected at least one host-scoped schema") + } + for _, s := range schemas { + ok := false + for _, sc := range s.Scopes { + if sc == schema.ScopeHost { + ok = true + } + } + if !ok { + t.Errorf("schema %s is not host-scoped", s.ModuleID) + } + } +} + +func TestGetCaddyConfig(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) // GET returns "null" + useAPIMode(t, srv.URL) + + rec := doRequest(h.GetCaddyConfig(), http.MethodGet, "/caddy/config", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } +} + +func TestGetCaddyConfigRequiresAPIMode(t *testing.T) { + h := newTestHandler(t) + prev := config.Configuration.Caddy.Mode + config.Configuration.Caddy.Mode = "caddyfile" + t.Cleanup(func() { config.Configuration.Caddy.Mode = prev }) + + rec := doRequest(h.GetCaddyConfig(), http.MethodGet, "/caddy/config", nil, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 in caddyfile mode, got %d", rec.Code) + } +} + +func TestGetCaddyModules(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake shell binary not supported on windows") + } + h := newTestHandler(t) + + script := filepath.Join(t.TempDir(), "fakecaddy") + body := `#!/bin/sh +cat <<'EOF' +[ + {"module_name":"http.handlers.reverse_proxy","module_type":"standard"}, + {"module_name":"dns.providers.cloudflare","module_type":"non-standard"} +] +EOF +` + if err := os.WriteFile(script, []byte(body), 0755); err != nil { + t.Fatal(err) + } + prev := config.Configuration.Caddy.Binary + config.Configuration.Caddy.Binary = script + t.Cleanup(func() { config.Configuration.Caddy.Binary = prev }) + + rec := doRequest(h.GetCaddyModules(), http.MethodGet, "/caddy/modules", nil, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + var resp struct { + Modules []schema.Schema `json:"modules"` + Plugins []struct { + ID string `json:"id"` + } `json:"plugins"` + } + decodeResult(t, rec, &resp) + if len(resp.Plugins) != 1 || resp.Plugins[0].ID != "dns.providers.cloudflare" { + t.Errorf("expected cloudflare as the only plugin, got %+v", resp.Plugins) + } +} + +func TestGetCaddyModulesBinaryError(t *testing.T) { + h := newTestHandler(t) + prev := config.Configuration.Caddy.Binary + config.Configuration.Caddy.Binary = "/nonexistent/caddy" + t.Cleanup(func() { config.Configuration.Caddy.Binary = prev }) + + rec := doRequest(h.GetCaddyModules(), http.MethodGet, "/caddy/modules", nil, nil) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500 on binary error, got %d", rec.Code) + } +} + +func TestDeleteModuleConfig(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + mc := database.ModuleConfig{ModuleID: "x", Path: "apps/http", Config: database.JSON(`{}`), Enabled: true} + if err := h.DB.Create(&mc).Error; err != nil { + t.Fatal(err) + } + + rec := doRequest(h.DeleteModuleConfig(), http.MethodDelete, "/caddy/module-configs/1", nil, + map[string]string{"id": "1"}) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var count int64 + h.DB.Model(&database.ModuleConfig{}).Count(&count) + if count != 0 { + t.Fatalf("expected config deleted, count=%d", count) + } +} + +func TestDeleteModuleConfigRequiresAPIMode(t *testing.T) { + h := newTestHandler(t) + prev := config.Configuration.Caddy.Mode + config.Configuration.Caddy.Mode = "caddyfile" + t.Cleanup(func() { config.Configuration.Caddy.Mode = prev }) + + rec := doRequest(h.DeleteModuleConfig(), http.MethodDelete, "/caddy/module-configs/1", nil, + map[string]string{"id": "1"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} diff --git a/backend/internal/api/handler/hostplugins.go b/backend/internal/api/handler/hostplugins.go new file mode 100644 index 0000000..67f5c63 --- /dev/null +++ b/backend/internal/api/handler/hostplugins.go @@ -0,0 +1,141 @@ +package handler + +import ( + "encoding/json" + "net/http" + + h "github.com/Pacerino/CaddyProxyManager/internal/api/http" + "github.com/Pacerino/CaddyProxyManager/internal/caddy" + "github.com/Pacerino/CaddyProxyManager/internal/caddy/schema" + "github.com/Pacerino/CaddyProxyManager/internal/database" + "github.com/Pacerino/CaddyProxyManager/internal/jobqueue" +) + +// GetHostScopedSchemas returns the typed schemas for plugins configurable per +// host. The frontend filters these against the host's Caddy build. +// Route: GET /caddy/host-schemas +func (s Handler) GetHostScopedSchemas() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + h.ResultResponseJSON(w, r, http.StatusOK, schema.WithScope(schema.ScopeHost)) + } +} + +// hostPluginRequest is the body accepted by SetHostPlugin. +type hostPluginRequest struct { + ModuleID string `json:"moduleId"` + Enabled *bool `json:"enabled,omitempty"` + Values map[string]any `json:"values"` +} + +// SetHostPlugin creates or updates a per-host plugin configuration and +// re-applies the host's route. +// Route: PUT /hosts/{hostID}/plugins +func (s Handler) SetHostPlugin() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + hostID, err := getURLParamInt(r, "hostID") + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + var req hostPluginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + sc, found := schema.Get(req.ModuleID) + if !found || !sc.HasScope(schema.ScopeHost) { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "module is not configurable per host", nil) + return + } + + handler, err := sc.BuildHandler(req.Values) + if err != nil { + if ve, ok := err.(*schema.ValidationError); ok { + h.ResultErrorJSON(w, r, http.StatusBadRequest, "schema validation failed", ve.Fields) + return + } + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + handlerJSON, err := json.Marshal(handler) + if err != nil { + h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) + return + } + + plugin := database.HostPlugin{ + HostID: uint(hostID), + ModuleID: req.ModuleID, + Handler: database.JSON(handlerJSON), + Enabled: true, + } + if req.Enabled != nil { + plugin.Enabled = *req.Enabled + } + + // Upsert by (host, module). + var existing database.HostPlugin + if err := s.DB.Where("host_id = ? AND module_id = ?", hostID, req.ModuleID).First(&existing).Error; err == nil { + plugin.ID = existing.ID + } + if err := s.DB.Save(&plugin).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.reapplyHost(uint(hostID)); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, plugin) + } +} + +// DeleteHostPlugin removes a per-host plugin and re-applies the host's route. +// Route: DELETE /hosts/{hostID}/plugins/{pluginID} +func (s Handler) DeleteHostPlugin() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + hostID, err := getURLParamInt(r, "hostID") + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + pluginID, err := getURLParamInt(r, "pluginID") + if err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.DB.Where("host_id = ?", hostID).Delete(&database.HostPlugin{}, pluginID).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + if err := s.reapplyHost(uint(hostID)); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + + h.ResultResponseJSON(w, r, http.StatusOK, true) + } +} + +// reapplyHost reloads the host with its associations and re-writes its +// configuration through the configured provider via the job queue. +func (s Handler) reapplyHost(hostID uint) error { + var host database.Host + if err := s.DB.Where("id = ?", hostID).Preload("Upstreams").Preload("Plugins").First(&host).Error; err != nil { + return err + } + provider, err := caddy.GetProvider() + if err != nil { + return err + } + return jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyConfigureHost", + Action: func() error { return provider.WriteHost(host) }, + }) +} diff --git a/backend/internal/api/handler/hostplugins_test.go b/backend/internal/api/handler/hostplugins_test.go new file mode 100644 index 0000000..caa0218 --- /dev/null +++ b/backend/internal/api/handler/hostplugins_test.go @@ -0,0 +1,162 @@ +package handler + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +func seedHost(t *testing.T, h Handler) database.Host { + t.Helper() + host := database.Host{Domains: "example.com", Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}}} + if err := h.DB.Create(&host).Error; err != nil { + t.Fatal(err) + } + return host +} + +func TestSetHostPluginBasicAuth(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + host := seedHost(t, h) + + body := map[string]any{ + "moduleId": "http.handlers.authentication", + "values": map[string]any{"username": "bob", "password": "s3cret"}, + } + rec := doRequest(h.SetHostPlugin(), http.MethodPut, "/hosts/1/plugins", body, + map[string]string{"hostID": "1"}) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var plugins []database.HostPlugin + h.DB.Where("host_id = ?", host.ID).Find(&plugins) + if len(plugins) != 1 { + t.Fatalf("expected 1 host plugin, got %d", len(plugins)) + } + + // The stored handler must contain the basic auth provider and a hashed + // (non-plaintext) password. + var handler struct { + Handler string `json:"handler"` + Providers struct { + HTTPBasic struct { + Accounts []struct { + Username string `json:"username"` + Password string `json:"password"` + } `json:"accounts"` + } `json:"http_basic"` + } `json:"providers"` + } + if err := json.Unmarshal(plugins[0].Handler, &handler); err != nil { + t.Fatalf("unmarshal handler: %v", err) + } + if handler.Handler != "authentication" { + t.Errorf("handler type = %q", handler.Handler) + } + acc := handler.Providers.HTTPBasic.Accounts + if len(acc) != 1 || acc[0].Username != "bob" { + t.Fatalf("accounts = %+v", acc) + } + if acc[0].Password == "" || acc[0].Password == "s3cret" { + t.Error("password should be hashed, not plaintext or empty") + } +} + +func TestSetHostPluginUpsertNoDuplicate(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + seedHost(t, h) + + for i := 0; i < 3; i++ { + body := map[string]any{ + "moduleId": "http.handlers.authentication", + "values": map[string]any{"username": "bob", "password": "pw"}, + } + rec := doRequest(h.SetHostPlugin(), http.MethodPut, "/hosts/1/plugins", body, + map[string]string{"hostID": "1"}) + if rec.Code != http.StatusOK { + t.Fatalf("update %d: status %d (%s)", i, rec.Code, rec.Body.String()) + } + } + drainJobQueue(t) + + var count int64 + h.DB.Model(&database.HostPlugin{}).Where("host_id = 1").Count(&count) + if count != 1 { + t.Fatalf("expected 1 host plugin after repeated saves, got %d", count) + } +} + +func TestSetHostPluginValidationFails(t *testing.T) { + h := newTestHandler(t) + seedHost(t, h) + + // Missing password -> schema validation error. + body := map[string]any{ + "moduleId": "http.handlers.authentication", + "values": map[string]any{"username": "bob"}, + } + rec := doRequest(h.SetHostPlugin(), http.MethodPut, "/hosts/1/plugins", body, + map[string]string{"hostID": "1"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (%s)", rec.Code, rec.Body.String()) + } +} + +func TestSetHostPluginRejectsNonHostScoped(t *testing.T) { + h := newTestHandler(t) + seedHost(t, h) + + // cloudflare is global-scoped, not allowed per host. + body := map[string]any{ + "moduleId": "dns.providers.cloudflare", + "values": map[string]any{"api_token": "x"}, + } + rec := doRequest(h.SetHostPlugin(), http.MethodPut, "/hosts/1/plugins", body, + map[string]string{"hostID": "1"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for non-host plugin, got %d", rec.Code) + } +} + +func TestDeleteHostPlugin(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + host := seedHost(t, h) + plugin := database.HostPlugin{ + HostID: host.ID, + ModuleID: "http.handlers.authentication", + Handler: database.JSON(`{"handler":"authentication"}`), + Enabled: true, + } + if err := h.DB.Create(&plugin).Error; err != nil { + t.Fatal(err) + } + + rec := doRequest(h.DeleteHostPlugin(), http.MethodDelete, "/hosts/1/plugins/1", nil, + map[string]string{"hostID": "1", "pluginID": "1"}) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var count int64 + h.DB.Model(&database.HostPlugin{}).Count(&count) + if count != 0 { + t.Fatalf("expected plugin deleted, count=%d", count) + } +} diff --git a/backend/internal/api/handler/hosts.go b/backend/internal/api/handler/hosts.go index 4e85936..0b4352d 100644 --- a/backend/internal/api/handler/hosts.go +++ b/backend/internal/api/handler/hosts.go @@ -17,7 +17,7 @@ import ( func (s Handler) GetHosts() func(http.ResponseWriter, *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var hosts []database.Host - if err := s.DB.Preload("Upstreams").Find(&hosts).Error; err != nil { + if err := s.DB.Preload("Upstreams").Preload("Plugins").Find(&hosts).Error; err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) } h.ResultResponseJSON(w, r, http.StatusOK, hosts) @@ -36,7 +36,7 @@ func (s Handler) GetHost() func(http.ResponseWriter, *http.Request) { return } - if err = s.DB.Where("id = ?", hostID).Preload("Upstreams").First(&host).Error; err != nil { + if err = s.DB.Where("id = ?", hostID).Preload("Upstreams").Preload("Plugins").First(&host).Error; err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) } else { h.ResultResponseJSON(w, r, http.StatusOK, host) @@ -71,11 +71,10 @@ func (s Handler) CreateHost() func(http.ResponseWriter, *http.Request) { return } + created := newHost if err := jobqueue.AddJob(jobqueue.Job{ - Name: "CaddyConfigureHost", - Action: func() error { - return provider.WriteHost(newHost) - }, + Name: "CaddyConfigureHost", + Action: func() error { return provider.WriteHost(created) }, }); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return @@ -106,23 +105,40 @@ func (s Handler) UpdateHost() func(http.ResponseWriter, *http.Request) { return } - if err := jobqueue.AddJob(jobqueue.Job{ - Name: "CaddyConfigureHost", - Action: func() error { - return provider.WriteHost(newHost) - }, + // Persist the host fields and replace its upstreams. Replace deletes + // upstreams that are no longer present and inserts the new set, so + // repeated edits don't accumulate duplicates. + if err := s.DB.Transaction(func(tx *gorm.DB) error { + upstreams := newHost.Upstreams + newHost.Upstreams = nil + if err := tx.Model(&database.Host{}). + Where("id = ?", newHost.ID). + Updates(map[string]any{"domains": newHost.Domains, "matcher": newHost.Matcher}).Error; err != nil { + return err + } + return tx.Model(&newHost).Association("Upstreams").Replace(upstreams) }); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return } - result := s.DB.Session(&gorm.Session{FullSaveAssociations: true}).Save(&newHost) - if result.Error != nil { - h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil) + // Re-read the persisted host (with associations) and apply that + // snapshot to Caddy. Reading a fresh copy avoids sharing the request's + // mutable struct with the async job goroutine. + var persisted database.Host + if err := s.DB.Where("id = ?", newHost.ID).Preload("Upstreams").Preload("Plugins").First(&persisted).Error; err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) + return + } + if err := jobqueue.AddJob(jobqueue.Job{ + Name: "CaddyConfigureHost", + Action: func() error { return provider.WriteHost(persisted) }, + }); err != nil { + h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return } - h.ResultResponseJSON(w, r, http.StatusOK, newHost) + h.ResultResponseJSON(w, r, http.StatusOK, persisted) } } diff --git a/backend/internal/api/handler/hosts_test.go b/backend/internal/api/handler/hosts_test.go new file mode 100644 index 0000000..15cd989 --- /dev/null +++ b/backend/internal/api/handler/hosts_test.go @@ -0,0 +1,172 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +func TestCreateHost(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + body := map[string]any{ + "domains": "example.com", + "matcher": "", + "Upstreams": []map[string]any{ + {"backend": "127.0.0.1:8080"}, + }, + } + rec := doRequest(h.CreateHost(), http.MethodPost, "/hosts", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var hosts []database.Host + h.DB.Preload("Upstreams").Find(&hosts) + if len(hosts) != 1 { + t.Fatalf("expected 1 host, got %d", len(hosts)) + } + if len(hosts[0].Upstreams) != 1 { + t.Fatalf("expected 1 upstream, got %d", len(hosts[0].Upstreams)) + } +} + +func TestCreateHostValidationFails(t *testing.T) { + h := newTestHandler(t) + // Missing domains -> validation error, no Caddy needed. + body := map[string]any{ + "domains": "", + "Upstreams": []map[string]any{{"backend": "127.0.0.1:8080"}}, + } + rec := doRequest(h.CreateHost(), http.MethodPost, "/hosts", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (%s)", rec.Code, rec.Body.String()) + } +} + +func TestGetAndListHost(t *testing.T) { + h := newTestHandler(t) + host := database.Host{Domains: "a.com", Upstreams: []database.Upstream{{Backend: "127.0.0.1:1"}}} + if err := h.DB.Create(&host).Error; err != nil { + t.Fatal(err) + } + + list := doRequest(h.GetHosts(), http.MethodGet, "/hosts", nil, nil) + var hosts []database.Host + decodeResult(t, list, &hosts) + if len(hosts) != 1 || hosts[0].Domains != "a.com" { + t.Fatalf("list returned %+v", hosts) + } + + get := doRequest(h.GetHost(), http.MethodGet, "/hosts/1", nil, map[string]string{"hostID": "1"}) + var got database.Host + decodeResult(t, get, &got) + if got.ID != host.ID || len(got.Upstreams) != 1 { + t.Fatalf("get returned %+v", got) + } +} + +// TestUpdateHostDoesNotDuplicateUpstreams is the regression test for the bug +// where repeated edits accumulated duplicate upstream rows. +func TestUpdateHostDoesNotDuplicateUpstreams(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + host := database.Host{ + Domains: "example.com", + Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}}, + } + if err := h.DB.Create(&host).Error; err != nil { + t.Fatal(err) + } + + // Simulate the frontend: send upstreams WITHOUT ids, several times. + for i := 0; i < 3; i++ { + body := map[string]any{ + "ID": host.ID, + "domains": "example.com", + "matcher": "", + "Upstreams": []map[string]any{ + {"backend": "127.0.0.1:8080"}, + {"backend": "127.0.0.1:8081"}, + }, + } + rec := doRequest(h.UpdateHost(), http.MethodPut, "/hosts", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("update %d: status %d (%s)", i, rec.Code, rec.Body.String()) + } + } + drainJobQueue(t) + + var upstreams []database.Upstream + h.DB.Where("host_id = ?", host.ID).Find(&upstreams) + if len(upstreams) != 2 { + t.Fatalf("expected 2 upstreams after repeated edits, got %d: %+v", len(upstreams), upstreams) + } +} + +func TestUpdateHostReducesUpstreams(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + host := database.Host{ + Domains: "example.com", + Upstreams: []database.Upstream{ + {Backend: "127.0.0.1:8080"}, + {Backend: "127.0.0.1:8081"}, + }, + } + if err := h.DB.Create(&host).Error; err != nil { + t.Fatal(err) + } + + body := map[string]any{ + "ID": host.ID, + "domains": "example.com", + "Upstreams": []map[string]any{{"backend": "127.0.0.1:9090"}}, + } + rec := doRequest(h.UpdateHost(), http.MethodPut, "/hosts", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var upstreams []database.Upstream + h.DB.Where("host_id = ?", host.ID).Find(&upstreams) + if len(upstreams) != 1 || upstreams[0].Backend != "127.0.0.1:9090" { + t.Fatalf("expected single replaced upstream, got %+v", upstreams) + } +} + +func TestDeleteHost(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + host := database.Host{Domains: "a.com", Upstreams: []database.Upstream{{Backend: "127.0.0.1:1"}}} + if err := h.DB.Create(&host).Error; err != nil { + t.Fatal(err) + } + + rec := doRequest(h.DeleteHost(), http.MethodDelete, "/hosts/1", nil, map[string]string{"hostID": "1"}) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var count int64 + h.DB.Model(&database.Host{}).Count(&count) + if count != 0 { + t.Fatalf("expected host deleted, count=%d", count) + } +} diff --git a/backend/internal/api/handler/login_test.go b/backend/internal/api/handler/login_test.go new file mode 100644 index 0000000..51df74a --- /dev/null +++ b/backend/internal/api/handler/login_test.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/auth" + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +// TestUserLoginHappyPath exercises the full local login flow: it relies on the +// global database instance and a generated JWT signing key. +func TestUserLoginHappyPath(t *testing.T) { + dir := t.TempDir() + prevData := config.Configuration.DataFolder + prevMode := config.Configuration.Auth.Mode + prevAdmin := config.Configuration.Admin + t.Cleanup(func() { + config.Configuration.DataFolder = prevData + config.Configuration.Auth.Mode = prevMode + config.Configuration.Admin = prevAdmin + }) + + config.Configuration.DataFolder = dir + config.Configuration.Auth.Mode = "local" + config.Configuration.Admin.Email = "admin@example.com" + config.Configuration.Admin.Password = "supersecret" + + // Create the JWT signing key and the database (which seeds the admin). + if err := auth.EnsureKey(); err != nil { + t.Fatalf("EnsureKey: %v", err) + } + database.NewDB() + + h := Handler{DB: database.GetInstance()} + + body := map[string]any{"Email": "admin@example.com", "Secret": "supersecret"} + rec := doRequest(h.UserLogin(), http.MethodPost, "/users/login", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("login status %d (%s)", rec.Code, rec.Body.String()) + } + var resp struct { + Token string `json:"token"` + Expires int64 `json:"expires"` + } + decodeResult(t, rec, &resp) + if resp.Token == "" { + t.Error("expected a token on successful login") + } + + // Wrong password -> unauthorized. + bad := map[string]any{"Email": "admin@example.com", "Secret": "wrong"} + rec = doRequest(h.UserLogin(), http.MethodPost, "/users/login", bad, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for wrong password, got %d", rec.Code) + } + + // Unknown user -> unauthorized (record not found). + missing := map[string]any{"Email": "ghost@example.com", "Secret": "x"} + rec = doRequest(h.UserLogin(), http.MethodPost, "/users/login", missing, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for unknown user, got %d", rec.Code) + } +} diff --git a/backend/internal/api/handler/moduleconfig_test.go b/backend/internal/api/handler/moduleconfig_test.go new file mode 100644 index 0000000..20a7159 --- /dev/null +++ b/backend/internal/api/handler/moduleconfig_test.go @@ -0,0 +1,116 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +func TestSetModuleConfigWithSchema(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + body := map[string]any{ + "moduleId": "dns.providers.cloudflare", + "values": map[string]any{"api_token": "tok"}, + } + rec := doRequest(h.SetModuleConfig(), http.MethodPut, "/caddy/module-configs", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var configs []database.ModuleConfig + h.DB.Find(&configs) + if len(configs) != 1 { + t.Fatalf("expected 1 module config, got %d", len(configs)) + } + // Schema default path should be applied. + if configs[0].Path != "apps/tls/automation/policies" { + t.Errorf("path = %q", configs[0].Path) + } +} + +func TestSetModuleConfigSchemaValidationFails(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + + body := map[string]any{ + "moduleId": "dns.providers.cloudflare", + "values": map[string]any{}, // missing api_token + } + rec := doRequest(h.SetModuleConfig(), http.MethodPut, "/caddy/module-configs", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (%s)", rec.Code, rec.Body.String()) + } +} + +func TestSetModuleConfigRawFallback(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + startJobQueue(t) + + // Unknown module -> raw JSON fallback, path required. + body := map[string]any{ + "moduleId": "http.handlers.some_plugin", + "path": "apps/http/servers/srv0", + "config": map[string]any{"foo": "bar"}, + } + rec := doRequest(h.SetModuleConfig(), http.MethodPut, "/caddy/module-configs", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + drainJobQueue(t) + + var count int64 + h.DB.Model(&database.ModuleConfig{}).Count(&count) + if count != 1 { + t.Fatalf("expected 1 config, got %d", count) + } +} + +func TestSetModuleConfigRawRequiresConfig(t *testing.T) { + h := newTestHandler(t) + srv := fakeCaddyServer(t) + useAPIMode(t, srv.URL) + + body := map[string]any{"moduleId": "http.handlers.some_plugin", "path": "x"} + rec := doRequest(h.SetModuleConfig(), http.MethodPut, "/caddy/module-configs", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 when raw config missing, got %d", rec.Code) + } +} + +func TestModuleConfigRequiresAPIMode(t *testing.T) { + h := newTestHandler(t) + prev := config.Configuration.Caddy.Mode + config.Configuration.Caddy.Mode = "caddyfile" + t.Cleanup(func() { config.Configuration.Caddy.Mode = prev }) + + body := map[string]any{ + "moduleId": "dns.providers.cloudflare", + "values": map[string]any{"api_token": "tok"}, + } + rec := doRequest(h.SetModuleConfig(), http.MethodPut, "/caddy/module-configs", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 in caddyfile mode, got %d (%s)", rec.Code, rec.Body.String()) + } +} + +func TestGetModuleConfigs(t *testing.T) { + h := newTestHandler(t) + h.DB.Create(&database.ModuleConfig{ModuleID: "x", Path: "p", Config: database.JSON(`{}`), Enabled: true}) + + rec := doRequest(h.GetModuleConfigs(), http.MethodGet, "/caddy/module-configs", nil, nil) + var configs []database.ModuleConfig + decodeResult(t, rec, &configs) + if len(configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(configs)) + } +} diff --git a/backend/internal/api/handler/testmain_test.go b/backend/internal/api/handler/testmain_test.go new file mode 100644 index 0000000..253c2f6 --- /dev/null +++ b/backend/internal/api/handler/testmain_test.go @@ -0,0 +1,134 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" + "github.com/Pacerino/CaddyProxyManager/internal/jobqueue" + + "github.com/glebarez/sqlite" + "github.com/go-chi/chi/v5" + "gorm.io/gorm" +) + +// newTestHandler returns a Handler backed by a fresh in-memory SQLite database +// with the schema migrated. Each test gets an isolated database (private +// in-memory connection, single connection so it persists for the test). +func newTestHandler(t *testing.T) Handler { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?_pragma=foreign_keys(1)"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + // A private in-memory DB lives only as long as its single connection, so + // pin the pool to one connection for the test's lifetime. + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + + if err := db.AutoMigrate(&database.Host{}, &database.Upstream{}, &database.HostPlugin{}, &database.User{}, &database.ModuleConfig{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return Handler{DB: db} +} + +// fakeCaddyServer starts an httptest server that accepts the admin API calls +// the APIProvider makes, so WriteHost/Apply succeed without a real Caddy. +func fakeCaddyServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GET traversal endpoints return "null" (absent) so the provider + // bootstraps; everything else returns 200. + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("null")) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return srv +} + +// useAPIMode points the global config at the fake Caddy and uses API mode with +// no reload side effects. It restores the previous config on cleanup. +func useAPIMode(t *testing.T, adminURL string) { + t.Helper() + prev := config.Configuration.Caddy + config.Configuration.Caddy.Mode = "api" + config.Configuration.Caddy.AdminURL = adminURL + config.Configuration.Caddy.ServerName = "srv0" + config.Configuration.Caddy.Listen = []string{":80"} + config.Configuration.Caddy.ReloadStrategy = "none" + t.Cleanup(func() { config.Configuration.Caddy = prev }) +} + +// startJobQueue starts the async job worker for tests that enqueue jobs and +// stops it on cleanup. Jobs run on a single worker goroutine. +func startJobQueue(t *testing.T) { + t.Helper() + jobqueue.Start() + t.Cleanup(func() { _ = jobqueue.Shutdown() }) +} + +// drainJobQueue blocks until all previously enqueued jobs have run, by +// enqueueing a sentinel job and waiting for it to execute. The worker is a +// single goroutine processing jobs in order, so once the sentinel runs every +// earlier job has completed. +func drainJobQueue(t *testing.T) { + t.Helper() + done := make(chan struct{}) + if err := jobqueue.AddJob(jobqueue.Job{ + Name: "TestSentinel", + Action: func() error { close(done); return nil }, + }); err != nil { + t.Fatalf("enqueue sentinel: %v", err) + } + <-done +} + +// doRequest invokes a handler func with an optional JSON body and chi URL +// params, returning the recorded response. +func doRequest(handler http.HandlerFunc, method, target string, body any, urlParams map[string]string) *httptest.ResponseRecorder { + var reader *bytes.Reader + if body != nil { + buf, _ := json.Marshal(body) + reader = bytes.NewReader(buf) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, target, reader) + if len(urlParams) > 0 { + rctx := chi.NewRouteContext() + for k, v := range urlParams { + rctx.URLParams.Add(k, v) + } + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + } + rec := httptest.NewRecorder() + handler(rec, req) + return rec +} + +// decodeResult unmarshals the { result } envelope into out. +func decodeResult(t *testing.T, rec *httptest.ResponseRecorder, out any) { + t.Helper() + var env struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v (body=%s)", err, rec.Body.String()) + } + if out != nil && len(env.Result) > 0 { + if err := json.Unmarshal(env.Result, out); err != nil { + t.Fatalf("decode result: %v (result=%s)", err, env.Result) + } + } +} diff --git a/backend/internal/api/handler/user_test.go b/backend/internal/api/handler/user_test.go new file mode 100644 index 0000000..7964f01 --- /dev/null +++ b/backend/internal/api/handler/user_test.go @@ -0,0 +1,116 @@ +package handler + +import ( + "net/http" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +func TestCreateUserHashesSecret(t *testing.T) { + h := newTestHandler(t) + body := map[string]any{ + "Name": "Alice", + "Email": "alice@example.com", + "secret": "password123", + } + rec := doRequest(h.CreateUser(), http.MethodPost, "/users", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + + var user database.User + if err := h.DB.Where("email = ?", "alice@example.com").First(&user).Error; err != nil { + t.Fatal(err) + } + if user.Secret == "" || user.Secret == "password123" { + t.Error("secret should be stored as a hash") + } +} + +func TestCreateUserValidationFails(t *testing.T) { + h := newTestHandler(t) + // Missing email -> validation error. + body := map[string]any{"Name": "NoEmail", "secret": "x"} + rec := doRequest(h.CreateUser(), http.MethodPost, "/users", body, nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestGetUsersAndGetUser(t *testing.T) { + h := newTestHandler(t) + u := database.User{Name: "Bob", Email: "bob@example.com", Secret: "h"} + if err := h.DB.Create(&u).Error; err != nil { + t.Fatal(err) + } + + list := doRequest(h.GetUsers(), http.MethodGet, "/users", nil, nil) + var users []database.User + decodeResult(t, list, &users) + if len(users) != 1 || users[0].Email != "bob@example.com" { + t.Fatalf("users = %+v", users) + } + + get := doRequest(h.GetUser(), http.MethodGet, "/users/1", nil, map[string]string{"userID": "1"}) + var got database.User + decodeResult(t, get, &got) + if got.ID != u.ID { + t.Fatalf("got user %+v", got) + } +} + +func TestGetUserNotFound(t *testing.T) { + h := newTestHandler(t) + rec := doRequest(h.GetUser(), http.MethodGet, "/users/99", nil, map[string]string{"userID": "99"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing user, got %d", rec.Code) + } +} + +func TestUpdateUser(t *testing.T) { + h := newTestHandler(t) + u := database.User{Name: "Carol", Email: "carol@example.com", Secret: "h"} + if err := h.DB.Create(&u).Error; err != nil { + t.Fatal(err) + } + + body := map[string]any{"ID": u.ID, "Name": "Caroline", "Email": "carol@example.com"} + rec := doRequest(h.UpdateUser(), http.MethodPut, "/users", body, nil) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + + var got database.User + h.DB.First(&got, u.ID) + if got.Name != "Caroline" { + t.Errorf("name not updated: %q", got.Name) + } +} + +func TestDeleteUser(t *testing.T) { + h := newTestHandler(t) + u := database.User{Name: "Dave", Email: "dave@example.com", Secret: "h"} + if err := h.DB.Create(&u).Error; err != nil { + t.Fatal(err) + } + + rec := doRequest(h.DeleteUser(), http.MethodDelete, "/users/1", nil, map[string]string{"userID": "1"}) + if rec.Code != http.StatusOK { + t.Fatalf("status %d (%s)", rec.Code, rec.Body.String()) + } + + var count int64 + h.DB.Unscoped().Model(&database.User{}).Count(&count) + if count != 0 { + t.Fatalf("expected user removed, count=%d", count) + } +} + +func TestDeleteUserNotFound(t *testing.T) { + h := newTestHandler(t) + rec := doRequest(h.DeleteUser(), http.MethodDelete, "/users/99", nil, map[string]string{"userID": "99"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} diff --git a/backend/internal/api/http/http_test.go b/backend/internal/api/http/http_test.go new file mode 100644 index 0000000..13490e7 --- /dev/null +++ b/backend/internal/api/http/http_test.go @@ -0,0 +1,91 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + c "github.com/Pacerino/CaddyProxyManager/internal/api/context" +) + +func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var env map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v (body=%s)", err, rec.Body.String()) + } + return env +} + +func TestResultResponseJSON(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ResultResponseJSON(rec, req, http.StatusOK, map[string]any{"hello": "world"}) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + env := decodeEnvelope(t, rec) + result := env["result"].(map[string]any) + if result["hello"] != "world" { + t.Errorf("result = %v", result) + } +} + +func TestResultResponseJSONPrettyPrint(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req = req.WithContext(context.WithValue(req.Context(), c.PrettyPrintCtxKey, true)) + ResultResponseJSON(rec, req, http.StatusOK, map[string]any{"a": 1}) + // Pretty output contains newlines/indentation. + if !json.Valid(rec.Body.Bytes()) { + t.Error("pretty output should still be valid JSON") + } +} + +func TestResultErrorJSON(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ResultErrorJSON(rec, req, http.StatusBadRequest, "bad", map[string]string{"field": "x"}) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } + env := decodeEnvelope(t, rec) + errObj := env["error"].(map[string]any) + if errObj["message"] != "bad" { + t.Errorf("error = %v", errObj) + } +} + +func TestValidateRequestSchema(t *testing.T) { + schema := `{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}` + + // Valid payload. + errs, err := ValidateRequestSchema(schema, []byte(`{"name":"bob"}`)) + if err != nil || len(errs) != 0 { + t.Fatalf("valid payload: errs=%v err=%v", errs, err) + } + + // Missing required field. + errs, _ = ValidateRequestSchema(schema, []byte(`{}`)) + if len(errs) == 0 { + t.Error("expected schema validation errors for missing field") + } + + // Non-JSON body. + if _, err := ValidateRequestSchema(schema, []byte("not json")); err != ErrInvalidJSON { + t.Errorf("expected ErrInvalidJSON, got %v", err) + } +} + +func TestIsJSON(t *testing.T) { + if !isJSON([]byte(`{"a":1}`)) { + t.Error("object should be json") + } + if isJSON([]byte("nope")) { + t.Error("garbage should not be json") + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 1ae29ec..3b42e25 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -45,6 +45,24 @@ func generateRoutes(r chi.Router, h *handler.Handler) chi.Router { r.Get("/{hostID:[0-9]+}", h.GetHost()) // Get specific Host by ID r.Delete("/{hostID:[0-9]+}", h.DeleteHost()) // Delete Host by ID r.Put("/", h.UpdateHost()) // Update Host by ID + + // Per-host plugin configuration. + r.Put("/{hostID:[0-9]+}/plugins", h.SetHostPlugin()) // Create/update a host plugin + r.Delete("/{hostID:[0-9]+}/plugins/{pluginID:[0-9]+}", h.DeleteHostPlugin()) // Remove a host plugin + }) + + //Caddy (modules, schemas & live config) + r.With(middleware.Enforce()).Route("/caddy", func(r chi.Router) { + r.Get("/modules", h.GetCaddyModules()) // List modules/plugins of the caddy build + r.Get("/schemas", h.GetCaddySchemas()) // Global-scope config schemas + r.Get("/host-schemas", h.GetHostScopedSchemas()) // Host-scope config schemas + r.Get("/config", h.GetCaddyConfig()) // Read live caddy config (api mode) + + r.Route("/module-configs", func(r chi.Router) { + r.Get("/", h.GetModuleConfigs()) // List stored module configs + r.Put("/", h.SetModuleConfig()) // Create/update + apply a module config + r.Delete("/{id:[0-9]+}", h.DeleteModuleConfig()) // Delete a module config + }) }) //Auth diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go new file mode 100644 index 0000000..8e97d07 --- /dev/null +++ b/backend/internal/auth/auth_test.go @@ -0,0 +1,64 @@ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestGenerateSecret(t *testing.T) { + if _, err := GenerateSecret(""); err == nil { + t.Error("expected error for empty password") + } + hash, err := GenerateSecret("hunter2") + if err != nil { + t.Fatalf("GenerateSecret: %v", err) + } + if hash == "hunter2" || hash == "" { + t.Error("secret should be a non-empty hash") + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("hunter2")); err != nil { + t.Errorf("hash does not verify: %v", err) + } +} + +func TestLoadPemPrivateAndPublicKey(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + privPem := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + pubPem := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509.MarshalPKCS1PublicKey(&key.PublicKey), + }) + + priv, err := LoadPemPrivateKey(privPem) + if err != nil { + t.Fatalf("LoadPemPrivateKey: %v", err) + } + if priv.N.Cmp(key.N) != 0 { + t.Error("loaded private key differs") + } + + pub, err := LoadPemPublicKey(pubPem) + if err != nil { + t.Fatalf("LoadPemPublicKey: %v", err) + } + if pub.N.Cmp(key.PublicKey.N) != 0 { + t.Error("loaded public key differs") + } +} + +func TestLoadPemPrivateKeyInvalid(t *testing.T) { + if _, err := LoadPemPrivateKey([]byte("-----BEGIN RSA PRIVATE KEY-----\nbad\n-----END RSA PRIVATE KEY-----")); err == nil { + t.Error("expected error for invalid PEM") + } +} diff --git a/backend/internal/auth/keys.go b/backend/internal/auth/keys.go index ca83a2c..d07feec 100644 --- a/backend/internal/auth/keys.go +++ b/backend/internal/auth/keys.go @@ -4,6 +4,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "fmt" "io" "os" @@ -58,12 +59,13 @@ func GetPrivateKey() (*rsa.PrivateKey, error) { // LoadPemPrivateKey reads a key from a PEM encoded string and returns a private key func LoadPemPrivateKey(content []byte) (*rsa.PrivateKey, error) { - var key *rsa.PrivateKey data, _ := pem.Decode(content) - var err error - key, err = x509.ParsePKCS1PrivateKey(data.Bytes) + if data == nil { + return nil, errors.New("no PEM data found in private key") + } + key, err := x509.ParsePKCS1PrivateKey(data.Bytes) if err != nil { - return key, err + return nil, err } return key, nil } @@ -90,11 +92,13 @@ func GetPublicKey() (*rsa.PublicKey, error) { // LoadPemPublicKey reads a key from a PEM encoded string and returns a public key func LoadPemPublicKey(content []byte) (*rsa.PublicKey, error) { - var key *rsa.PublicKey data, _ := pem.Decode(content) + if data == nil { + return nil, errors.New("no PEM data found in public key") + } publicKeyFileImported, err := x509.ParsePKCS1PublicKey(data.Bytes) if err != nil { - return key, err + return nil, err } return publicKeyFileImported, nil diff --git a/backend/internal/auth/keys_jwt_test.go b/backend/internal/auth/keys_jwt_test.go new file mode 100644 index 0000000..4c6d24c --- /dev/null +++ b/backend/internal/auth/keys_jwt_test.go @@ -0,0 +1,88 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +// resetKeyCache clears the package-level key cache so a test controls loading. +func resetKeyCache() { + privateKey = nil + publicKey = nil +} + +func TestEnsureKeyAndLoad(t *testing.T) { + dir := t.TempDir() + prev := config.Configuration.DataFolder + t.Cleanup(func() { + config.Configuration.DataFolder = prev + resetKeyCache() + }) + config.Configuration.DataFolder = dir + resetKeyCache() + + // First call generates the key. + if err := EnsureKey(); err != nil { + t.Fatalf("EnsureKey: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "jwtkey.pem")); err != nil { + t.Fatalf("key file not created: %v", err) + } + // Second call is a no-op (key exists). + if err := EnsureKey(); err != nil { + t.Fatalf("EnsureKey idempotent: %v", err) + } + + priv, err := GetPrivateKey() + if err != nil { + t.Fatalf("GetPrivateKey: %v", err) + } + if priv == nil { + t.Fatal("expected a private key") + } +} + +func TestGenerateJWT(t *testing.T) { + dir := t.TempDir() + prev := config.Configuration.DataFolder + t.Cleanup(func() { + config.Configuration.DataFolder = prev + resetKeyCache() + }) + config.Configuration.DataFolder = dir + resetKeyCache() + if err := EnsureKey(); err != nil { + t.Fatal(err) + } + + user := &database.User{} + user.ID = 99 + resp, err := Generate(user) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if resp.Token == "" { + t.Error("expected a non-empty token") + } + if resp.Expires == 0 { + t.Error("expected an expiry") + } +} + +func TestGetPrivateKeyMissing(t *testing.T) { + prev := config.Configuration.DataFolder + t.Cleanup(func() { + config.Configuration.DataFolder = prev + resetKeyCache() + }) + config.Configuration.DataFolder = filepath.Join(t.TempDir(), "no-key-here") + resetKeyCache() + + if _, err := GetPrivateKey(); err == nil { + t.Error("expected error when key file is absent") + } +} diff --git a/backend/internal/caddy/api.go b/backend/internal/caddy/api.go index c90a3da..408e596 100644 --- a/backend/internal/caddy/api.go +++ b/backend/internal/caddy/api.go @@ -56,6 +56,32 @@ func (p *APIProvider) WriteHost(h database.Host) error { return p.do(http.MethodPost, path, route, nil) } +// GetConfig returns the live Caddy configuration at the given path. Pass an +// empty path for the full config. The result is decoded into out, which is +// typically a *json.RawMessage or *map[string]any. +func (p *APIProvider) GetConfig(path string, out any) error { + full := "/config/" + strings.TrimPrefix(strings.TrimPrefix(path, "/config/"), "/") + status, data, err := p.request(http.MethodGet, full, nil) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("caddy admin GET %s returned %d: %s", full, status, string(data)) + } + if out == nil || len(data) == 0 { + return nil + } + return json.Unmarshal(data, out) +} + +// PatchConfig merges body into the config at path using the admin API. Caddy's +// PATCH replaces the value at the addressed path, so callers should send the +// full object they want present at that path. +func (p *APIProvider) PatchConfig(path string, body any) error { + full := "/config/" + strings.TrimPrefix(strings.TrimPrefix(path, "/config/"), "/") + return p.do(http.MethodPatch, full, body, nil) +} + // RemoveHost deletes the route for a host. func (p *APIProvider) RemoveHost(hostID int) error { id := hostRouteID(uint(hostID)) diff --git a/backend/internal/caddy/apiconfig_test.go b/backend/internal/caddy/apiconfig_test.go new file mode 100644 index 0000000..2597726 --- /dev/null +++ b/backend/internal/caddy/apiconfig_test.go @@ -0,0 +1,178 @@ +package caddy + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +// recordingCaddy records requests and serves configurable GET responses. +type recordingCaddy struct { + getBody string // body returned for GET requests + requests []recordedRequest +} + +type recordedRequest struct { + method string + path string + body string +} + +func (c *recordingCaddy) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + c.requests = append(c.requests, recordedRequest{r.Method, r.URL.Path, string(raw)}) + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + body := c.getBody + if body == "" { + body = "null" + } + _, _ = w.Write([]byte(body)) + return + } + w.WriteHeader(http.StatusOK) + }) +} + +func (c *recordingCaddy) find(method, path string) *recordedRequest { + for i := range c.requests { + if c.requests[i].method == method && c.requests[i].path == path { + return &c.requests[i] + } + } + return nil +} + +func TestGetConfig(t *testing.T) { + fake := &recordingCaddy{getBody: `{"listen":[":80"]}`} + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + p := newTestProvider(srv.URL) + + var out map[string]any + if err := p.GetConfig("apps/http", &out); err != nil { + t.Fatalf("GetConfig: %v", err) + } + if fake.find(http.MethodGet, "/config/apps/http") == nil { + t.Fatalf("expected GET /config/apps/http, got %+v", fake.requests) + } + if _, ok := out["listen"]; !ok { + t.Errorf("decoded config missing listen: %v", out) + } +} + +func TestGetConfigNormalisesLeadingPaths(t *testing.T) { + fake := &recordingCaddy{getBody: "null"} + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + p := newTestProvider(srv.URL) + + // A path that already includes /config/ and a leading slash should not + // double up. + if err := p.GetConfig("/config/apps", nil); err != nil { + t.Fatal(err) + } + if fake.find(http.MethodGet, "/config/apps") == nil { + t.Errorf("path not normalised: %+v", fake.requests) + } +} + +func TestPatchConfig(t *testing.T) { + fake := &recordingCaddy{} + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + p := newTestProvider(srv.URL) + + if err := p.PatchConfig("apps/tls", map[string]any{"x": 1}); err != nil { + t.Fatalf("PatchConfig: %v", err) + } + req := fake.find(http.MethodPatch, "/config/apps/tls") + if req == nil { + t.Fatalf("expected PATCH /config/apps/tls, got %+v", fake.requests) + } + var sent map[string]any + if err := json.Unmarshal([]byte(req.body), &sent); err != nil { + t.Fatalf("body not json: %v", err) + } + if sent["x"].(float64) != 1 { + t.Errorf("patched body = %v", sent) + } +} + +func TestApplyModuleConfigEnabled(t *testing.T) { + fake := &recordingCaddy{} + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + p := newTestProvider(srv.URL) + + mc := database.ModuleConfig{ + ModuleID: "dns.providers.cloudflare", + Path: "apps/tls/automation/policies", + Config: database.JSON(`{"issuers":[]}`), + Enabled: true, + } + if err := p.ApplyModuleConfig(mc); err != nil { + t.Fatalf("ApplyModuleConfig: %v", err) + } + if fake.find(http.MethodPatch, "/config/apps/tls/automation/policies") == nil { + t.Errorf("expected PATCH to module path, got %+v", fake.requests) + } +} + +func TestApplyModuleConfigDisabledRemoves(t *testing.T) { + // getBody non-null so configExists reports present and a DELETE follows. + fake := &recordingCaddy{getBody: `{"present":true}`} + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + p := newTestProvider(srv.URL) + + mc := database.ModuleConfig{ + ModuleID: "dns.providers.cloudflare", + Path: "apps/tls/automation/policies", + Config: database.JSON(`{"issuers":[]}`), + Enabled: false, + } + if err := p.ApplyModuleConfig(mc); err != nil { + t.Fatalf("ApplyModuleConfig disabled: %v", err) + } + if fake.find(http.MethodDelete, "/config/apps/tls/automation/policies") == nil { + t.Errorf("expected DELETE for disabled config, got %+v", fake.requests) + } +} + +func TestRemoveModuleConfigMissingIsNoop(t *testing.T) { + fake := &recordingCaddy{getBody: "null"} // not present + srv := httptest.NewServer(fake.handler()) + defer srv.Close() + p := newTestProvider(srv.URL) + + mc := database.ModuleConfig{Path: "apps/tls/automation/policies"} + if err := p.RemoveModuleConfig(mc); err != nil { + t.Fatalf("RemoveModuleConfig: %v", err) + } + for _, r := range fake.requests { + if r.method == http.MethodDelete { + t.Errorf("expected no DELETE for missing config, got %+v", fake.requests) + } + } +} + +func TestTrimConfigPrefix(t *testing.T) { + cases := map[string]string{ + "apps/http": "apps/http", + "/apps/http": "apps/http", + "///apps/http": "apps/http", + "config/apps/http": "apps/http", + "/config/apps/http": "apps/http", + } + for in, want := range cases { + if got := trimConfigPrefix(in); got != want { + t.Errorf("trimConfigPrefix(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/backend/internal/caddy/caddy.go b/backend/internal/caddy/caddy.go index 5de7887..9235dcb 100644 --- a/backend/internal/caddy/caddy.go +++ b/backend/internal/caddy/caddy.go @@ -1,6 +1,7 @@ package caddy import ( + "encoding/json" "fmt" "os" @@ -12,6 +13,11 @@ import ( "github.com/aymerick/raymond" ) +// jsonUnmarshal unmarshals a database.JSON value. +func jsonUnmarshal(data database.JSON, v any) error { + return json.Unmarshal(data, v) +} + // CaddyfileProvider applies host configuration by rendering a per-host // Caddyfile snippet into the data folder and reloading Caddy. type CaddyfileProvider struct{} @@ -19,6 +25,43 @@ type CaddyfileProvider struct{} type hostEntity struct { Host database.Host LogPath string + // BasicAuth carries username/hash pairs derived from the host's + // authentication plugin so the Caddyfile template can render a + // basic_auth directive. + BasicAuth []basicAuthAccount +} + +type basicAuthAccount struct { + User string + Hash string +} + +// basicAuthAccounts extracts username/hash pairs from a host's enabled +// authentication plugins for Caddyfile rendering. +func basicAuthAccounts(h database.Host) []basicAuthAccount { + var out []basicAuthAccount + for _, p := range h.Plugins { + if !p.Enabled || p.ModuleID != "http.handlers.authentication" || len(p.Handler) == 0 { + continue + } + var handler struct { + Providers struct { + HTTPBasic struct { + Accounts []struct { + Username string `json:"username"` + Password string `json:"password"` + } `json:"accounts"` + } `json:"http_basic"` + } `json:"providers"` + } + if err := jsonUnmarshal(p.Handler, &handler); err != nil { + continue + } + for _, a := range handler.Providers.HTTPBasic.Accounts { + out = append(out, basicAuthAccount{User: a.Username, Hash: a.Password}) + } + } + return out } func hostConfigPath(hostID uint) string { @@ -28,8 +71,9 @@ func hostConfigPath(hostID uint) string { // WriteHost renders the host template and writes it to the config folder. func (p *CaddyfileProvider) WriteHost(h database.Host) error { data := &hostEntity{ - Host: h, - LogPath: fmt.Sprintf("%s/host_%d.log", config.Configuration.LogFolder, h.ID), + Host: h, + LogPath: fmt.Sprintf("%s/host_%d.log", config.Configuration.LogFolder, h.ID), + BasicAuth: basicAuthAccounts(h), } filename := hostConfigPath(h.ID) // Read Template from Embed FS diff --git a/backend/internal/caddy/caddyfile_test.go b/backend/internal/caddy/caddyfile_test.go new file mode 100644 index 0000000..3329906 --- /dev/null +++ b/backend/internal/caddy/caddyfile_test.go @@ -0,0 +1,146 @@ +package caddy + +import ( + "os" + "strings" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/config" + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +// useCaddyfileTempDirs points the config at temp folders and disables reloads +// so the CaddyfileProvider can write files without side effects. +func useCaddyfileTempDirs(t *testing.T) string { + t.Helper() + dir := t.TempDir() + prev := config.Configuration + config.Configuration.DataFolder = dir + config.Configuration.LogFolder = dir + config.Configuration.Caddy.ReloadStrategy = "none" + t.Cleanup(func() { config.Configuration = prev }) + return dir +} + +func TestCaddyfileWriteHost(t *testing.T) { + dir := useCaddyfileTempDirs(t) + p := &CaddyfileProvider{} + + host := database.Host{ + Domains: "example.com", + Matcher: "/api/*", + Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}}, + } + host.ID = 5 + + if err := p.WriteHost(host); err != nil { + t.Fatalf("WriteHost: %v", err) + } + + data, err := os.ReadFile(hostConfigPath(5)) + if err != nil { + t.Fatalf("read config: %v", err) + } + out := string(data) + if !strings.Contains(out, "example.com") { + t.Errorf("missing domain in output: %s", out) + } + if !strings.Contains(out, "reverse_proxy") || !strings.Contains(out, "127.0.0.1:8080") { + t.Errorf("missing reverse_proxy/upstream: %s", out) + } + _ = dir +} + +func TestCaddyfileWriteHostWithBasicAuth(t *testing.T) { + useCaddyfileTempDirs(t) + p := &CaddyfileProvider{} + + host := database.Host{ + Domains: "secure.example.com", + Upstreams: []database.Upstream{{Backend: "127.0.0.1:9000"}}, + Plugins: []database.HostPlugin{ + { + ModuleID: "http.handlers.authentication", + Enabled: true, + Handler: database.JSON(`{"handler":"authentication","providers":{"http_basic":{"accounts":[{"username":"bob","password":"hashed=="}]}}}`), + }, + }, + } + host.ID = 6 + + if err := p.WriteHost(host); err != nil { + t.Fatalf("WriteHost: %v", err) + } + data, _ := os.ReadFile(hostConfigPath(6)) + out := string(data) + if !strings.Contains(out, "basic_auth") { + t.Errorf("expected basic_auth block: %s", out) + } + if !strings.Contains(out, "bob") || !strings.Contains(out, "hashed==") { + t.Errorf("expected account in output: %s", out) + } +} + +func TestCaddyfileRemoveHost(t *testing.T) { + useCaddyfileTempDirs(t) + p := &CaddyfileProvider{} + + host := database.Host{Domains: "a.com", Upstreams: []database.Upstream{{Backend: "127.0.0.1:1"}}} + host.ID = 7 + if err := p.WriteHost(host); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(hostConfigPath(7)); err != nil { + t.Fatalf("config should exist: %v", err) + } + + if err := p.RemoveHost(7); err != nil { + t.Fatalf("RemoveHost: %v", err) + } + if _, err := os.Stat(hostConfigPath(7)); !os.IsNotExist(err) { + t.Errorf("config should be removed, stat err = %v", err) + } + + // Removing a missing host is a no-op (file already gone). + if err := p.RemoveHost(7); err != nil { + t.Errorf("RemoveHost on missing should be noop, got %v", err) + } +} + +func TestBasicAuthAccounts(t *testing.T) { + host := database.Host{ + Plugins: []database.HostPlugin{ + { + ModuleID: "http.handlers.authentication", + Enabled: true, + Handler: database.JSON(`{"providers":{"http_basic":{"accounts":[{"username":"u1","password":"p1"}]}}}`), + }, + { + ModuleID: "http.handlers.authentication", + Enabled: false, // skipped + Handler: database.JSON(`{"providers":{"http_basic":{"accounts":[{"username":"skip","password":"x"}]}}}`), + }, + { + ModuleID: "other.module", // skipped + Enabled: true, + Handler: database.JSON(`{}`), + }, + }, + } + + accounts := basicAuthAccounts(host) + if len(accounts) != 1 { + t.Fatalf("expected 1 account, got %d: %+v", len(accounts), accounts) + } + if accounts[0].User != "u1" || accounts[0].Hash != "p1" { + t.Errorf("account = %+v", accounts[0]) + } +} + +func TestHostConfigPath(t *testing.T) { + useCaddyfileTempDirs(t) + got := hostConfigPath(42) + if !strings.HasSuffix(got, "/host_42.conf") { + t.Errorf("hostConfigPath = %q", got) + } +} diff --git a/backend/internal/caddy/json.go b/backend/internal/caddy/json.go index 478adaa..9fbf4e6 100644 --- a/backend/internal/caddy/json.go +++ b/backend/internal/caddy/json.go @@ -1,6 +1,7 @@ package caddy import ( + "encoding/json" "fmt" "strings" @@ -25,14 +26,18 @@ func buildRoute(h database.Host) map[string]any { upstreams = append(upstreams, map[string]any{"dial": u.Backend}) } - handler := map[string]any{ + proxy := map[string]any{ "handler": "reverse_proxy", "upstreams": upstreams, } + // Host plugin handlers (e.g. authentication) run before the reverse proxy. + handle := pluginHandlers(h) + handle = append(handle, proxy) + route := map[string]any{ "@id": hostRouteID(h.ID), - "handle": []map[string]any{handler}, + "handle": handle, "match": []map[string]any{{"host": hosts}}, "terminal": true, } @@ -40,6 +45,23 @@ func buildRoute(h database.Host) map[string]any { return route } +// pluginHandlers renders the enabled host plugin handlers for a host, in a +// stable order. Invalid or empty handler JSON is skipped. +func pluginHandlers(h database.Host) []map[string]any { + handlers := make([]map[string]any, 0, len(h.Plugins)) + for _, p := range h.Plugins { + if !p.Enabled || len(p.Handler) == 0 { + continue + } + var handler map[string]any + if err := json.Unmarshal(p.Handler, &handler); err != nil || len(handler) == 0 { + continue + } + handlers = append(handlers, handler) + } + return handlers +} + // splitDomains turns a space separated domain list into a slice. func splitDomains(domains string) []string { fields := strings.Fields(domains) diff --git a/backend/internal/caddy/json_test.go b/backend/internal/caddy/json_test.go index 6dd3030..9e5ef9b 100644 --- a/backend/internal/caddy/json_test.go +++ b/backend/internal/caddy/json_test.go @@ -65,3 +65,37 @@ func TestBuildRoute(t *testing.T) { t.Errorf("upstreams = %v", ups) } } + +func TestBuildRouteInjectsHostPlugins(t *testing.T) { + h := database.Host{ + Domains: "example.com", + Upstreams: []database.Upstream{{Backend: "127.0.0.1:8080"}}, + Plugins: []database.HostPlugin{ + { + ModuleID: "http.handlers.authentication", + Enabled: true, + Handler: database.JSON(`{"handler":"authentication"}`), + }, + { + ModuleID: "http.handlers.authentication", + Enabled: false, // disabled -> skipped + Handler: database.JSON(`{"handler":"ignored"}`), + }, + }, + } + h.ID = 1 + + route := buildRoute(h) + handle := route["handle"].([]map[string]any) + + // Plugin handler must come first, reverse_proxy last. + if len(handle) != 2 { + t.Fatalf("expected 2 handlers, got %d: %v", len(handle), handle) + } + if handle[0]["handler"] != "authentication" { + t.Errorf("first handler = %v, want authentication", handle[0]["handler"]) + } + if handle[len(handle)-1]["handler"] != "reverse_proxy" { + t.Errorf("last handler = %v, want reverse_proxy", handle[len(handle)-1]["handler"]) + } +} diff --git a/backend/internal/caddy/listmodules_test.go b/backend/internal/caddy/listmodules_test.go new file mode 100644 index 0000000..d3a1ec8 --- /dev/null +++ b/backend/internal/caddy/listmodules_test.go @@ -0,0 +1,117 @@ +package caddy + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/config" +) + +// TestListModules points the configured Caddy binary at a fake script that +// emits a known list-modules JSON payload, exercising the exec + parse path. +func TestListModules(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake shell binary not supported on windows") + } + prev := config.Configuration.Caddy.Binary + t.Cleanup(func() { config.Configuration.Caddy.Binary = prev }) + + script := filepath.Join(t.TempDir(), "fakecaddy") + body := `#!/bin/sh +cat <<'EOF' +[ + {"module_name":"http.handlers.reverse_proxy","module_type":"standard","version":"v2","package_url":"caddy"}, + {"module_name":"dns.providers.cloudflare","module_type":"non-standard","version":"","package_url":"caddy-dns/cloudflare"} +] +EOF +` + if err := os.WriteFile(script, []byte(body), 0755); err != nil { + t.Fatal(err) + } + config.Configuration.Caddy.Binary = script + + mods, err := ListModules() + if err != nil { + t.Fatalf("ListModules: %v", err) + } + if len(mods) != 2 { + t.Fatalf("expected 2 modules, got %d", len(mods)) + } + // Sorted: dns.providers.cloudflare first. + if mods[0].ID != "dns.providers.cloudflare" || mods[0].Standard { + t.Errorf("unexpected first module: %+v", mods[0]) + } +} + +// TestListModulesTextFallback simulates an older Caddy that rejects --json but +// supports the plain-text flags, exercising the text-parsing fallback. +func TestListModulesTextFallback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake shell binary not supported on windows") + } + prev := config.Configuration.Caddy.Binary + t.Cleanup(func() { config.Configuration.Caddy.Binary = prev }) + + script := filepath.Join(t.TempDir(), "fakecaddy") + body := `#!/bin/sh +# Reject --json like older caddy does. +for a in "$@"; do + if [ "$a" = "--json" ]; then + echo "Error: unknown flag: --json" >&2 + exit 1 + fi +done +# --skip-standard returns only the plugin(s). +case "$*" in + *--skip-standard*) + echo "dns.providers.cloudflare github.com/caddy-dns/cloudflare" + ;; + *) + echo "http.handlers.reverse_proxy github.com/caddyserver/caddy/v2" + echo "dns.providers.cloudflare github.com/caddy-dns/cloudflare" + echo "" + echo " Standard modules: 1" + ;; +esac +` + if err := os.WriteFile(script, []byte(body), 0755); err != nil { + t.Fatal(err) + } + config.Configuration.Caddy.Binary = script + + mods, err := ListModules() + if err != nil { + t.Fatalf("ListModules text fallback: %v", err) + } + if len(mods) != 2 { + t.Fatalf("expected 2 modules, got %d: %+v", len(mods), mods) + } + + byID := map[string]Module{} + for _, m := range mods { + byID[m.ID] = m + } + cf, ok := byID["dns.providers.cloudflare"] + if !ok || cf.Standard { + t.Errorf("cloudflare should be present and non-standard: %+v", cf) + } + if cf.Package != "github.com/caddy-dns/cloudflare" { + t.Errorf("cloudflare package = %q", cf.Package) + } + rp, ok := byID["http.handlers.reverse_proxy"] + if !ok || !rp.Standard { + t.Errorf("reverse_proxy should be present and standard: %+v", rp) + } +} + +func TestListModulesBinaryMissing(t *testing.T) { + prev := config.Configuration.Caddy.Binary + t.Cleanup(func() { config.Configuration.Caddy.Binary = prev }) + + config.Configuration.Caddy.Binary = "/nonexistent/definitely/not/caddy" + if _, err := ListModules(); err == nil { + t.Error("expected error when binary is missing") + } +} diff --git a/backend/internal/caddy/moduleconfig.go b/backend/internal/caddy/moduleconfig.go new file mode 100644 index 0000000..f0dc6a3 --- /dev/null +++ b/backend/internal/caddy/moduleconfig.go @@ -0,0 +1,49 @@ +package caddy + +import ( + "encoding/json" + + "github.com/Pacerino/CaddyProxyManager/internal/database" +) + +// ApplyModuleConfig patches a module configuration fragment into the live +// Caddy config via the admin API. When the fragment is disabled it is removed +// instead. Only supported in API mode (callers must use APIProvider). +func (p *APIProvider) ApplyModuleConfig(mc database.ModuleConfig) error { + if !mc.Enabled { + return p.RemoveModuleConfig(mc) + } + var body any + if len(mc.Config) > 0 { + if err := json.Unmarshal(mc.Config, &body); err != nil { + return err + } + } + return p.PatchConfig(mc.Path, body) +} + +// RemoveModuleConfig removes a module configuration fragment from the live +// config. Caddy returns an error for a missing path; that is treated as +// already-removed and ignored. +func (p *APIProvider) RemoveModuleConfig(mc database.ModuleConfig) error { + exists, err := p.configExists("/config/" + trimConfigPrefix(mc.Path)) + if err != nil { + return err + } + if !exists { + return nil + } + return p.do("DELETE", "/config/"+trimConfigPrefix(mc.Path), nil, nil) +} + +// trimConfigPrefix normalises a stored path so it can be appended to /config/. +func trimConfigPrefix(path string) string { + for len(path) > 0 && path[0] == '/' { + path = path[1:] + } + const prefix = "config/" + if len(path) >= len(prefix) && path[:len(prefix)] == prefix { + path = path[len(prefix):] + } + return path +} diff --git a/backend/internal/caddy/modules.go b/backend/internal/caddy/modules.go new file mode 100644 index 0000000..aa24c2f --- /dev/null +++ b/backend/internal/caddy/modules.go @@ -0,0 +1,185 @@ +package caddy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "github.com/Pacerino/CaddyProxyManager/internal/config" +) + +// Module describes a single Caddy module (plugin) reported by +// `caddy list-modules --json`. +type Module struct { + // ID is the full dotted module name, e.g. "http.handlers.reverse_proxy" + // or "dns.providers.cloudflare". + ID string `json:"id"` + // Namespace is the module's parent namespace, derived from ID. For + // "dns.providers.cloudflare" it is "dns.providers". + Namespace string `json:"namespace"` + // Name is the leaf segment of the ID, e.g. "cloudflare". + Name string `json:"name"` + // Standard reports whether the module ships with stock Caddy. Anything + // that is not "standard" is treated as a user-supplied plugin. + Standard bool `json:"standard"` + // Version and Package are informational, taken from the build info. + Version string `json:"version,omitempty"` + Package string `json:"package,omitempty"` +} + +// rawModule mirrors the JSON object emitted by `caddy list-modules --json`. +type rawModule struct { + ModuleName string `json:"module_name"` + ModuleType string `json:"module_type"` + Version string `json:"version"` + PackageURL string `json:"package_url"` +} + +// ListModules returns the module list reported by the configured Caddy binary, +// sorted by ID. It is the canonical way to discover which plugins a +// user-supplied Caddy build provides. +// +// It prefers `caddy list-modules --json`, but older Caddy versions don't +// support the --json flag; in that case it falls back to parsing the plain +// text output. +func ListModules() ([]Module, error) { + out, jsonErr := runListModules("--json") + if jsonErr == nil { + return parseModules(out) + } + // Older Caddy without --json support: fall back to text parsing. + return listModulesText() +} + +// runListModules runs `caddy list-modules ` and returns stdout, +// surfacing stderr in the error message. +func runListModules(args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, config.Configuration.Caddy.Binary, append([]string{"list-modules"}, args...)...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("running %q list-modules: %s", config.Configuration.Caddy.Binary, msg) + } + return out, nil +} + +// listModulesText builds the module list from Caddy's plain-text output, used +// when the binary doesn't support --json. It runs once for the full list +// (with package paths) and once skipping standard modules to classify which +// modules are non-standard plugins. +func listModulesText() ([]Module, error) { + full, err := runListModules("--packages") + if err != nil { + return nil, err + } + // --skip-standard yields only the non-standard (plugin) module IDs. + skipStd, err := runListModules("--skip-standard", "--packages") + if err != nil { + // Without classification, treat everything as standard rather than fail. + skipStd = nil + } + + nonStandard := map[string]bool{} + for _, line := range parseTextModuleLines(skipStd) { + nonStandard[line.id] = true + } + + lines := parseTextModuleLines(full) + modules := make([]Module, 0, len(lines)) + for _, l := range lines { + namespace, name := splitModuleID(l.id) + modules = append(modules, Module{ + ID: l.id, + Namespace: namespace, + Name: name, + Standard: !nonStandard[l.id], + Package: l.pkg, + }) + } + + sort.Slice(modules, func(i, j int) bool { return modules[i].ID < modules[j].ID }) + return modules, nil +} + +type textModuleLine struct { + id string + pkg string +} + +// parseTextModuleLines parses lines of `caddy list-modules --packages` output. +// Each module line looks like "http.handlers.reverse_proxy github.com/...". +// Summary/blank lines (e.g. "Standard modules: 132") are skipped. +func parseTextModuleLines(data []byte) []textModuleLine { + var out []textModuleLine + for _, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + fields := strings.Fields(line) + id := fields[0] + // A module ID is a dotted token; summary lines like "Standard + // modules: 132" start with a word and a colon, so skip non-module ids. + if !strings.Contains(id, ".") || strings.HasSuffix(id, ":") { + continue + } + pkg := "" + if len(fields) > 1 { + pkg = fields[len(fields)-1] + } + out = append(out, textModuleLine{id: id, pkg: pkg}) + } + return out +} + +// parseModules decodes the JSON output of `caddy list-modules --json`. +func parseModules(data []byte) ([]Module, error) { + var raw []rawModule + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing list-modules output: %w", err) + } + + modules := make([]Module, 0, len(raw)) + for _, rm := range raw { + id := strings.TrimSpace(rm.ModuleName) + if id == "" { + continue + } + namespace, name := splitModuleID(id) + modules = append(modules, Module{ + ID: id, + Namespace: namespace, + Name: name, + Standard: rm.ModuleType == "standard", + Version: rm.Version, + Package: rm.PackageURL, + }) + } + + sort.Slice(modules, func(i, j int) bool { return modules[i].ID < modules[j].ID }) + return modules, nil +} + +// splitModuleID splits a dotted module ID into its namespace and leaf name. +// For "dns.providers.cloudflare" it returns ("dns.providers", "cloudflare"). +// For a top-level id like "http" it returns ("", "http"). +func splitModuleID(id string) (namespace, name string) { + idx := strings.LastIndex(id, ".") + if idx < 0 { + return "", id + } + return id[:idx], id[idx+1:] +} diff --git a/backend/internal/caddy/modules_test.go b/backend/internal/caddy/modules_test.go new file mode 100644 index 0000000..6f8908f --- /dev/null +++ b/backend/internal/caddy/modules_test.go @@ -0,0 +1,60 @@ +package caddy + +import "testing" + +const sampleListModules = `[ + {"module_name":"http.handlers.reverse_proxy","module_type":"standard","version":"v2.11.4","package_url":"github.com/caddyserver/caddy/v2"}, + {"module_name":"dns.providers.cloudflare","module_type":"non-standard","version":"","package_url":"github.com/caddy-dns/cloudflare"}, + {"module_name":"http","module_type":"standard","version":"v2.11.4","package_url":"github.com/caddyserver/caddy/v2"}, + {"module_name":" ","module_type":"standard"} +]` + +func TestParseModules(t *testing.T) { + mods, err := parseModules([]byte(sampleListModules)) + if err != nil { + t.Fatalf("parseModules: %v", err) + } + + // The blank module_name entry must be dropped. + if len(mods) != 3 { + t.Fatalf("expected 3 modules, got %d: %+v", len(mods), mods) + } + + // Sorted by ID: dns.providers.cloudflare, http, http.handlers.reverse_proxy + if mods[0].ID != "dns.providers.cloudflare" { + t.Errorf("expected sorted first id dns.providers.cloudflare, got %q", mods[0].ID) + } + + cf := mods[0] + if cf.Namespace != "dns.providers" || cf.Name != "cloudflare" { + t.Errorf("split wrong: namespace=%q name=%q", cf.Namespace, cf.Name) + } + if cf.Standard { + t.Error("cloudflare should be flagged non-standard (plugin)") + } + if cf.Package != "github.com/caddy-dns/cloudflare" { + t.Errorf("package not carried through: %q", cf.Package) + } + + // Top-level module without a dot. + if mods[1].ID != "http" || mods[1].Namespace != "" || mods[1].Name != "http" { + t.Errorf("top-level module split wrong: %+v", mods[1]) + } + if !mods[2].Standard { + t.Error("reverse_proxy should be standard") + } +} + +func TestSplitModuleID(t *testing.T) { + cases := map[string][2]string{ + "dns.providers.cloudflare": {"dns.providers", "cloudflare"}, + "http.handlers.reverse_proxy": {"http.handlers", "reverse_proxy"}, + "http": {"", "http"}, + } + for id, want := range cases { + ns, name := splitModuleID(id) + if ns != want[0] || name != want[1] { + t.Errorf("splitModuleID(%q) = (%q,%q), want (%q,%q)", id, ns, name, want[0], want[1]) + } + } +} diff --git a/backend/internal/caddy/reload_test.go b/backend/internal/caddy/reload_test.go new file mode 100644 index 0000000..86c8fa0 --- /dev/null +++ b/backend/internal/caddy/reload_test.go @@ -0,0 +1,92 @@ +package caddy + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/Pacerino/CaddyProxyManager/internal/config" +) + +func TestReloadNone(t *testing.T) { + prev := config.Configuration.Caddy.ReloadStrategy + t.Cleanup(func() { config.Configuration.Caddy.ReloadStrategy = prev }) + + config.Configuration.Caddy.ReloadStrategy = "none" + if err := Reload(); err != nil { + t.Errorf("none strategy should be a no-op, got %v", err) + } +} + +func TestReloadUnknownStrategy(t *testing.T) { + prev := config.Configuration.Caddy.ReloadStrategy + t.Cleanup(func() { config.Configuration.Caddy.ReloadStrategy = prev }) + + config.Configuration.Caddy.ReloadStrategy = "bogus" + if err := Reload(); err == nil { + t.Error("expected error for unknown reload strategy") + } +} + +func TestReloadAPI(t *testing.T) { + prevCaddy := config.Configuration.Caddy + prevFile := config.Configuration.CaddyFile + t.Cleanup(func() { + config.Configuration.Caddy = prevCaddy + config.Configuration.CaddyFile = prevFile + }) + + var loaded bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/load" && r.Method == http.MethodPost { + loaded = true + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + file := filepath.Join(t.TempDir(), "Caddyfile") + if err := os.WriteFile(file, []byte("example.com {\n}\n"), 0644); err != nil { + t.Fatal(err) + } + + config.Configuration.Caddy.ReloadStrategy = "api" + config.Configuration.Caddy.AdminURL = srv.URL + config.Configuration.CaddyFile = file + + if err := Reload(); err != nil { + t.Fatalf("reload api: %v", err) + } + if !loaded { + t.Error("expected /load to be called") + } +} + +func TestReloadAPIServerError(t *testing.T) { + prevCaddy := config.Configuration.Caddy + prevFile := config.Configuration.CaddyFile + t.Cleanup(func() { + config.Configuration.Caddy = prevCaddy + config.Configuration.CaddyFile = prevFile + }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + file := filepath.Join(t.TempDir(), "Caddyfile") + _ = os.WriteFile(file, []byte("x"), 0644) + + config.Configuration.Caddy.ReloadStrategy = "api" + config.Configuration.Caddy.AdminURL = srv.URL + config.Configuration.CaddyFile = file + + if err := Reload(); err == nil { + t.Error("expected error on non-2xx /load response") + } +} diff --git a/backend/internal/caddy/schema/basicauth.go b/backend/internal/caddy/schema/basicauth.go new file mode 100644 index 0000000..3c410a1 --- /dev/null +++ b/backend/internal/caddy/schema/basicauth.go @@ -0,0 +1,61 @@ +package schema + +import ( + "encoding/base64" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +func init() { + Register(&Schema{ + ModuleID: "http.handlers.authentication", + Title: "HTTP Basic Authentication", + Description: "Protect this host with HTTP basic auth. Enter a plaintext password; CPM stores only the bcrypt hash Caddy needs.", + Scopes: []Scope{ScopeHost}, + Fields: []Field{ + { + Key: "username", + Label: "Username", + Type: FieldString, + Required: true, + }, + { + Key: "password", + Label: "Password", + Description: "Plaintext password. It is bcrypt-hashed before being stored.", + Type: FieldSecret, + Required: true, + }, + }, + buildHandler: func(values map[string]any) (map[string]any, error) { + username, _ := values["username"].(string) + password, _ := values["password"].(string) + if username == "" || password == "" { + return nil, fmt.Errorf("username and password are required") + } + + // Caddy's http_basic expects the account password as a + // base64-encoded bcrypt hash. + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + encoded := base64.StdEncoding.EncodeToString(hash) + + return map[string]any{ + "handler": "authentication", + "providers": map[string]any{ + "http_basic": map[string]any{ + "accounts": []any{ + map[string]any{ + "username": username, + "password": encoded, + }, + }, + }, + }, + }, nil + }, + }) +} diff --git a/backend/internal/caddy/schema/cloudflare.go b/backend/internal/caddy/schema/cloudflare.go new file mode 100644 index 0000000..8f3b58e --- /dev/null +++ b/backend/internal/caddy/schema/cloudflare.go @@ -0,0 +1,39 @@ +package schema + +func init() { + Register(&Schema{ + ModuleID: "dns.providers.cloudflare", + Title: "Cloudflare DNS", + Description: "DNS provider used for ACME DNS-01 challenges via the Cloudflare API.", + Scopes: []Scope{ScopeGlobal}, + Path: "apps/tls/automation/policies", + Fields: []Field{ + { + Key: "api_token", + Label: "API Token", + Description: "Cloudflare API token with DNS edit permissions.", + Type: FieldSecret, + Required: true, + }, + }, + build: func(values map[string]any) (any, error) { + // Caddy's tls automation policy referencing the cloudflare DNS + // provider for the ACME issuer's DNS-01 challenge. + return map[string]any{ + "issuers": []any{ + map[string]any{ + "module": "acme", + "challenges": map[string]any{ + "dns": map[string]any{ + "provider": map[string]any{ + "name": "cloudflare", + "api_token": values["api_token"], + }, + }, + }, + }, + }, + }, nil + }, + }) +} diff --git a/backend/internal/caddy/schema/schema.go b/backend/internal/caddy/schema/schema.go new file mode 100644 index 0000000..0bdb712 --- /dev/null +++ b/backend/internal/caddy/schema/schema.go @@ -0,0 +1,196 @@ +// Package schema describes how known Caddy modules (plugins) are configured +// through CPM. Each registered Schema drives a typed form in the UI and is +// validated before being applied. Modules without a registered schema fall +// back to a raw JSON editor. +package schema + +import ( + "encoding/json" + "fmt" + "sort" +) + +// FieldType enumerates the input types a schema field can render as. +type FieldType string + +const ( + FieldString FieldType = "string" + FieldInt FieldType = "int" + FieldBool FieldType = "bool" + // FieldSecret is a string that the UI should mask. + FieldSecret FieldType = "secret" +) + +// Scope describes where a plugin is configured. +type Scope string + +const ( + // ScopeGlobal: configured once for the whole Caddy instance (e.g. a DNS + // provider used for TLS issuance). Managed on the Plugins page. + ScopeGlobal Scope = "global" + // ScopeHost: configured per host, rendered as a route handler attached to + // the host's route. Managed inside the host editor. + ScopeHost Scope = "host" +) + +// Field describes a single configurable property of a module. +type Field struct { + // Key is the JSON property name within the module's config object. + Key string `json:"key"` + // Label and Description are human-facing UI hints. + Label string `json:"label"` + Description string `json:"description,omitempty"` + Type FieldType `json:"type"` + Required bool `json:"required,omitempty"` + Default any `json:"default,omitempty"` + Placeholder string `json:"placeholder,omitempty"` +} + +// Schema is the typed configuration descriptor for one Caddy module. +type Schema struct { + // ModuleID is the dotted Caddy module name, e.g. "dns.providers.cloudflare". + ModuleID string `json:"moduleId"` + // Title and Description describe the plugin to the user. + Title string `json:"title"` + Description string `json:"description,omitempty"` + // Scopes lists where this plugin can be configured (global, host or both). + Scopes []Scope `json:"scopes"` + // Path is the default admin API config path the rendered config is + // patched into, relative to /config/. Only used for global scope. + Path string `json:"path,omitempty"` + // Fields are the typed inputs presented in the UI. + Fields []Field `json:"fields"` + // build converts validated form values into the global Caddy JSON + // fragment. When nil, the values map is emitted as-is. + build func(values map[string]any) (any, error) + // buildHandler converts validated form values into a Caddy HTTP route + // handler object, injected into a host's route. Required for host scope. + buildHandler func(values map[string]any) (map[string]any, error) +} + +// HasScope reports whether the schema supports the given scope. +func (s *Schema) HasScope(scope Scope) bool { + for _, sc := range s.Scopes { + if sc == scope { + return true + } + } + return false +} + +var registry = map[string]*Schema{} + +// Register adds a schema to the registry. It is intended to be called from +// package init functions of individual module schema files. +func Register(s *Schema) { + registry[s.ModuleID] = s +} + +// Get returns the schema for a module ID and whether one is registered. +func Get(moduleID string) (*Schema, bool) { + s, ok := registry[moduleID] + return s, ok +} + +// All returns every registered schema, sorted by module ID. +func All() []*Schema { + out := make([]*Schema, 0, len(registry)) + for _, s := range registry { + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].ModuleID < out[j].ModuleID }) + return out +} + +// WithScope returns the registered schemas supporting the given scope, sorted +// by module ID. +func WithScope(scope Scope) []*Schema { + out := make([]*Schema, 0) + for _, s := range All() { + if s.HasScope(scope) { + out = append(out, s) + } + } + return out +} + +// Validate checks form values against the schema's field definitions, +// returning a map of field key -> error message for any problems. +func (s *Schema) Validate(values map[string]any) map[string]string { + errs := map[string]string{} + for _, f := range s.Fields { + v, present := values[f.Key] + if !present || v == nil || v == "" { + if f.Required { + errs[f.Key] = "is required" + } + continue + } + if msg := checkType(f, v); msg != "" { + errs[f.Key] = msg + } + } + return errs +} + +// Build validates and converts form values into the global Caddy JSON fragment. +func (s *Schema) Build(values map[string]any) (json.RawMessage, error) { + if errs := s.Validate(values); len(errs) > 0 { + return nil, &ValidationError{Fields: errs} + } + var fragment any = values + if s.build != nil { + built, err := s.build(values) + if err != nil { + return nil, err + } + fragment = built + } + return json.Marshal(fragment) +} + +// BuildHandler validates form values and renders the Caddy route handler +// object for a host-scoped plugin. +func (s *Schema) BuildHandler(values map[string]any) (map[string]any, error) { + if s.buildHandler == nil { + return nil, fmt.Errorf("module %q does not support host scope", s.ModuleID) + } + if errs := s.Validate(values); len(errs) > 0 { + return nil, &ValidationError{Fields: errs} + } + return s.buildHandler(values) +} + +// ValidationError carries per-field validation failures. +type ValidationError struct { + Fields map[string]string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("schema validation failed: %v", e.Fields) +} + +func checkType(f Field, v any) string { + switch f.Type { + case FieldString, FieldSecret: + if _, ok := v.(string); !ok { + return "must be a string" + } + case FieldInt: + // JSON numbers decode to float64. + switch n := v.(type) { + case float64: + if n != float64(int64(n)) { + return "must be an integer" + } + case int, int64: + default: + return "must be an integer" + } + case FieldBool: + if _, ok := v.(bool); !ok { + return "must be a boolean" + } + } + return "" +} diff --git a/backend/internal/caddy/schema/schema_test.go b/backend/internal/caddy/schema/schema_test.go new file mode 100644 index 0000000..a6208f4 --- /dev/null +++ b/backend/internal/caddy/schema/schema_test.go @@ -0,0 +1,142 @@ +package schema + +import ( + "encoding/json" + "testing" +) + +func TestRegistryHasKnownPlugins(t *testing.T) { + if _, ok := Get("dns.providers.cloudflare"); !ok { + t.Fatal("expected cloudflare schema to be registered") + } + if len(All()) < 2 { + t.Fatalf("expected at least 2 registered schemas, got %d", len(All())) + } +} + +func TestValidateRequired(t *testing.T) { + sc, _ := Get("dns.providers.cloudflare") + errs := sc.Validate(map[string]any{}) + if errs["api_token"] == "" { + t.Fatal("expected api_token required error") + } +} + +func TestValidateType(t *testing.T) { + sc, _ := Get("dns.providers.cloudflare") + errs := sc.Validate(map[string]any{"api_token": 123}) + if errs["api_token"] == "" { + t.Fatal("expected type error for non-string secret") + } +} + +func TestBuildRendersFragment(t *testing.T) { + sc, _ := Get("dns.providers.cloudflare") + raw, err := sc.Build(map[string]any{"api_token": "secret-token"}) + if err != nil { + t.Fatalf("Build: %v", err) + } + + var obj map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal built fragment: %v", err) + } + issuers, ok := obj["issuers"].([]any) + if !ok || len(issuers) != 1 { + t.Fatalf("expected 1 issuer, got %v", obj["issuers"]) + } +} + +func TestScopes(t *testing.T) { + cf, _ := Get("dns.providers.cloudflare") + if !cf.HasScope(ScopeGlobal) || cf.HasScope(ScopeHost) { + t.Errorf("cloudflare should be global-only, scopes=%v", cf.Scopes) + } + auth, _ := Get("http.handlers.authentication") + if !auth.HasScope(ScopeHost) || auth.HasScope(ScopeGlobal) { + t.Errorf("auth should be host-only, scopes=%v", auth.Scopes) + } + + if len(WithScope(ScopeHost)) < 1 { + t.Error("expected at least one host-scope schema") + } + if len(WithScope(ScopeGlobal)) < 1 { + t.Error("expected at least one global-scope schema") + } +} + +func TestBuildHandlerHashesPassword(t *testing.T) { + auth, _ := Get("http.handlers.authentication") + handler, err := auth.BuildHandler(map[string]any{"username": "bob", "password": "s3cret"}) + if err != nil { + t.Fatalf("BuildHandler: %v", err) + } + providers := handler["providers"].(map[string]any) + basic := providers["http_basic"].(map[string]any) + accounts := basic["accounts"].([]any) + acc := accounts[0].(map[string]any) + if acc["username"] != "bob" { + t.Errorf("username = %v", acc["username"]) + } + pw, _ := acc["password"].(string) + if pw == "" || pw == "s3cret" { + t.Error("password should be a non-plaintext hash") + } +} + +func TestBuildHandlerOnGlobalSchemaFails(t *testing.T) { + cf, _ := Get("dns.providers.cloudflare") + if _, err := cf.BuildHandler(map[string]any{"api_token": "x"}); err == nil { + t.Error("expected error building handler for a global-only schema") + } +} + +func TestCheckTypeAllFieldTypes(t *testing.T) { + s := &Schema{ + ModuleID: "test.types", + Scopes: []Scope{ScopeGlobal}, + Fields: []Field{ + {Key: "s", Type: FieldString, Required: true}, + {Key: "i", Type: FieldInt, Required: true}, + {Key: "b", Type: FieldBool, Required: true}, + {Key: "sec", Type: FieldSecret, Required: true}, + }, + } + + // All correct types -> no errors. + ok := s.Validate(map[string]any{ + "s": "txt", "i": float64(3), "b": true, "sec": "secret", + }) + if len(ok) != 0 { + t.Fatalf("expected no errors, got %v", ok) + } + + // All wrong types -> one error each. + bad := s.Validate(map[string]any{ + "s": 1, "i": 1.5, "b": "nope", "sec": 2, + }) + for _, k := range []string{"s", "i", "b", "sec"} { + if bad[k] == "" { + t.Errorf("expected type error for %q", k) + } + } +} + +func TestValidationErrorMessage(t *testing.T) { + ve := &ValidationError{Fields: map[string]string{"x": "is required"}} + if ve.Error() == "" { + t.Error("expected non-empty error message") + } +} + +func TestBuildReturnsValidationError(t *testing.T) { + sc, _ := Get("dns.providers.cloudflare") + _, err := sc.Build(map[string]any{}) + ve, ok := err.(*ValidationError) + if !ok { + t.Fatalf("expected *ValidationError, got %T", err) + } + if ve.Fields["api_token"] == "" { + t.Fatal("expected api_token in validation error fields") + } +} diff --git a/backend/internal/database/models.go b/backend/internal/database/models.go index 0002926..abf0c4a 100644 --- a/backend/internal/database/models.go +++ b/backend/internal/database/models.go @@ -1,12 +1,31 @@ package database -import "gorm.io/gorm" +import ( + "database/sql/driver" + "encoding/json" + "errors" + + "gorm.io/gorm" +) type Host struct { gorm.Model Domains string `json:"domains" validate:"required,domains"` Matcher string `json:"matcher"` Upstreams []Upstream + Plugins []HostPlugin +} + +// HostPlugin is a per-host plugin configuration. Handler holds the rendered +// Caddy route handler JSON (secrets already hashed), which is injected into +// the host's route. ModuleID is the dotted Caddy module name. +type HostPlugin struct { + gorm.Model + HostID uint `json:"hostId" gorm:"index"` + ModuleID string `json:"moduleId" validate:"required"` + // Handler is the rendered route handler object applied to the route. + Handler JSON `json:"handler"` + Enabled bool `json:"enabled" gorm:"default:true"` } type Upstream struct { @@ -15,6 +34,65 @@ type Upstream struct { Backend string `json:"backend" validate:"required,hostname_port"` } +// JSON is a json.RawMessage that persists as TEXT in SQLite via the +// Valuer/Scanner interfaces. +type JSON json.RawMessage + +// Value implements driver.Valuer. +func (j JSON) Value() (driver.Value, error) { + if len(j) == 0 { + return "null", nil + } + return string(j), nil +} + +// Scan implements sql.Scanner. +func (j *JSON) Scan(value any) error { + if value == nil { + *j = JSON("null") + return nil + } + switch v := value.(type) { + case []byte: + *j = append((*j)[0:0], v...) + case string: + *j = append((*j)[0:0], v...) + default: + return errors.New("unsupported type for JSON column") + } + return nil +} + +// MarshalJSON makes JSON serialise as raw JSON rather than a byte array. +func (j JSON) MarshalJSON() ([]byte, error) { + if len(j) == 0 { + return []byte("null"), nil + } + return j, nil +} + +// UnmarshalJSON captures raw JSON as-is. +func (j *JSON) UnmarshalJSON(data []byte) error { + *j = append((*j)[0:0], data...) + return nil +} + +// ModuleConfig stores a configuration fragment for a single Caddy module +// (plugin). Config is applied to the live Caddy config at Path via the admin +// API. ModuleID is the dotted Caddy module name, e.g. "dns.providers.cloudflare". +type ModuleConfig struct { + gorm.Model + // ModuleID is the dotted Caddy module name this fragment configures. + ModuleID string `json:"moduleId" gorm:"uniqueIndex" validate:"required"` + // Path is the admin API config path the fragment is patched into, + // e.g. "apps/tls/automation/policies". Relative to /config/. + Path string `json:"path" validate:"required"` + // Config is the JSON fragment patched into Path. + Config JSON `json:"config" validate:"required"` + // Enabled controls whether the fragment is applied on reconcile. + Enabled bool `json:"enabled" gorm:"default:true"` +} + type User struct { gorm.Model Name string `validate:"required"` diff --git a/backend/internal/database/models_test.go b/backend/internal/database/models_test.go new file mode 100644 index 0000000..40410ad --- /dev/null +++ b/backend/internal/database/models_test.go @@ -0,0 +1,81 @@ +package database + +import ( + "encoding/json" + "testing" +) + +func TestJSONValue(t *testing.T) { + // Empty -> "null". + v, err := JSON(nil).Value() + if err != nil { + t.Fatal(err) + } + if v != "null" { + t.Errorf("empty Value() = %v, want null", v) + } + + v, err = JSON(`{"a":1}`).Value() + if err != nil { + t.Fatal(err) + } + if v != `{"a":1}` { + t.Errorf("Value() = %v", v) + } +} + +func TestJSONScan(t *testing.T) { + var j JSON + if err := j.Scan([]byte(`{"a":1}`)); err != nil { + t.Fatal(err) + } + if string(j) != `{"a":1}` { + t.Errorf("Scan bytes = %s", j) + } + + if err := j.Scan(`{"b":2}`); err != nil { + t.Fatal(err) + } + if string(j) != `{"b":2}` { + t.Errorf("Scan string = %s", j) + } + + if err := j.Scan(nil); err != nil { + t.Fatal(err) + } + if string(j) != "null" { + t.Errorf("Scan nil = %s, want null", j) + } + + if err := j.Scan(12345); err == nil { + t.Error("expected error scanning unsupported type") + } +} + +func TestJSONMarshalUnmarshal(t *testing.T) { + // Marshalling embeds raw JSON rather than a base64 byte array. + type wrapper struct { + Data JSON `json:"data"` + } + out, err := json.Marshal(wrapper{Data: JSON(`{"x":true}`)}) + if err != nil { + t.Fatal(err) + } + if string(out) != `{"data":{"x":true}}` { + t.Errorf("marshal = %s", out) + } + + // Empty marshals to null. + out, _ = json.Marshal(wrapper{}) + if string(out) != `{"data":null}` { + t.Errorf("empty marshal = %s", out) + } + + var w wrapper + if err := json.Unmarshal([]byte(`{"data":{"y":5}}`), &w); err != nil { + t.Fatal(err) + } + if string(w.Data) != `{"y":5}` { + t.Errorf("unmarshal captured %s", w.Data) + } +} diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 40d7e96..04e5735 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -17,7 +17,7 @@ var dbInstance *gorm.DB func NewDB() { logger.Info("Creating new DB instance") dbInstance = SqliteDB() - dbInstance.AutoMigrate(&Host{}, &Upstream{}, &User{}) + dbInstance.AutoMigrate(&Host{}, &Upstream{}, &HostPlugin{}, &User{}, &ModuleConfig{}) seedAdmin() } diff --git a/backend/internal/jobqueue/jobqueue_test.go b/backend/internal/jobqueue/jobqueue_test.go new file mode 100644 index 0000000..2f5dbf5 --- /dev/null +++ b/backend/internal/jobqueue/jobqueue_test.go @@ -0,0 +1,86 @@ +package jobqueue + +import ( + "errors" + "testing" + "time" +) + +func TestAddJobBeforeStart(t *testing.T) { + saved := worker + worker = nil + t.Cleanup(func() { worker = saved }) + + if err := AddJob(Job{Name: "x", Action: func() error { return nil }}); err == nil { + t.Error("expected error adding job before Start") + } +} + +func TestShutdownBeforeStart(t *testing.T) { + saved := cancel + cancel = nil + t.Cleanup(func() { cancel = saved }) + + if err := Shutdown(); err == nil { + t.Error("expected error shutting down before Start") + } +} + +func TestStartRunJobAndShutdown(t *testing.T) { + Start() + defer func() { _ = Shutdown() }() + + runOne := func(name string) { + t.Helper() + done := make(chan struct{}) + if err := AddJob(Job{Name: name, Action: func() error { close(done); return nil }}); err != nil { + t.Fatalf("AddJob: %v", err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("job %q did not run within timeout", name) + } + } + + runOne("first") + runOne("second") +} + +func TestAddJobs(t *testing.T) { + Start() + // AddJobs cancels the queue context once all jobs are added, so no manual + // Shutdown is needed. + + const n = 3 + done := make(chan struct{}, n) + jobs := make([]Job, n) + for i := range jobs { + jobs[i] = Job{Name: "batch", Action: func() error { done <- struct{}{}; return nil }} + } + worker.Queue.AddJobs(jobs) + + for i := 0; i < n; i++ { + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("only %d/%d batch jobs ran", i, n) + } + } +} + +func TestJobRun(t *testing.T) { + ran := false + j := Job{Name: "t", Action: func() error { ran = true; return nil }} + if err := j.Run(); err != nil { + t.Fatalf("Run: %v", err) + } + if !ran { + t.Error("action did not run") + } + + jErr := Job{Name: "e", Action: func() error { return errors.New("x") }} + if err := jErr.Run(); err == nil { + t.Error("expected error from Run") + } +} diff --git a/backend/internal/util/interfaces_test.go b/backend/internal/util/interfaces_test.go new file mode 100644 index 0000000..3e6c8a7 --- /dev/null +++ b/backend/internal/util/interfaces_test.go @@ -0,0 +1,31 @@ +package util + +import "testing" + +func TestFindItemInInterface(t *testing.T) { + obj := map[string]interface{}{ + "a": 1, + "nested": map[string]interface{}{ + "target": "found", + }, + "list": []interface{}{ + map[string]interface{}{"deep": "value"}, + }, + } + + if v, ok := FindItemInInterface("a", obj); !ok || v != 1 { + t.Errorf("top level: got %v ok=%v", v, ok) + } + if v, ok := FindItemInInterface("target", obj); !ok || v != "found" { + t.Errorf("nested map: got %v ok=%v", v, ok) + } + if v, ok := FindItemInInterface("deep", obj); !ok || v != "value" { + t.Errorf("nested array: got %v ok=%v", v, ok) + } + if _, ok := FindItemInInterface("missing", obj); ok { + t.Error("missing key should not be found") + } + if _, ok := FindItemInInterface("a", "not a map"); ok { + t.Error("non-map input should return false") + } +} diff --git a/backend/internal/util/slices_test.go b/backend/internal/util/slices_test.go new file mode 100644 index 0000000..0d2bedd --- /dev/null +++ b/backend/internal/util/slices_test.go @@ -0,0 +1,57 @@ +package util + +import ( + "reflect" + "testing" +) + +func TestSliceContainsItem(t *testing.T) { + s := []string{"a", "b", "c"} + if !SliceContainsItem(s, "b") { + t.Error("expected to find b") + } + if SliceContainsItem(s, "z") { + t.Error("did not expect to find z") + } + if SliceContainsItem(nil, "a") { + t.Error("nil slice should contain nothing") + } +} + +func TestSliceContainsInt(t *testing.T) { + s := []int{1, 2, 3} + if !SliceContainsInt(s, 2) { + t.Error("expected to find 2") + } + if SliceContainsInt(s, 9) { + t.Error("did not expect to find 9") + } +} + +func TestConvertIntSliceToString(t *testing.T) { + tests := []struct { + in []int + want string + }{ + {nil, ""}, + {[]int{}, ""}, + {[]int{1}, "1"}, + {[]int{1, 2, 3}, "1,2,3"}, + } + for _, tt := range tests { + if got := ConvertIntSliceToString(tt.in); got != tt.want { + t.Errorf("ConvertIntSliceToString(%v) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestConvertStringSliceToInterface(t *testing.T) { + got := ConvertStringSliceToInterface([]string{"a", "b"}) + want := []interface{}{"a", "b"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + if len(ConvertStringSliceToInterface(nil)) != 0 { + t.Error("nil input should produce empty slice") + } +} diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 0000000..cf99255 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,43 @@ +# Per-PR end-to-end environment. CPM runs in API mode and manages Caddy via its +# admin API; whoami is a test upstream that CPM reverse-proxies to. +# +# The compose project name (set via -p cpm-pr- in CI) isolates the network, +# volume and container names per PR. CPM is published on a fixed host port so a +# Cloudflare Tunnel can target it. +services: + cpm: + image: ${CPM_IMAGE:?set CPM_IMAGE to the PR image tag} + container_name: cpm-pr-${PR_NUMBER:?set PR_NUMBER} + restart: unless-stopped + environment: + CPM_CADDY_MODE: api + CPM_CADDY_ADMINURL: http://localhost:2019 + # Caddy needs to listen on :80 inside the container for proxied requests. + CPM_CADDY_LISTEN: ":80" + CPM_ADMIN_EMAIL: ${CPM_ADMIN_EMAIL:-admin@example.com} + CPM_ADMIN_PASSWORD: ${CPM_ADMIN_PASSWORD:-changeme} + CPM_LOG_LEVEL: info + ports: + # Fixed published port for the tunnel + E2E (CPM API/UI). + - "3001:3001" + # Caddy's HTTP port, exposed so the E2E can verify proxied requests. + - "8080:80" + - "8443:443" + networks: + - cpm + labels: + cpm.pr: "${PR_NUMBER}" + + whoami: + image: traefik/whoami:latest + container_name: cpm-pr-${PR_NUMBER}-whoami + restart: unless-stopped + command: ["--port", "80"] + networks: + - cpm + labels: + cpm.pr: "${PR_NUMBER}" + +networks: + cpm: + name: cpm-pr-${PR_NUMBER} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index efe492e..45dc811 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -16,8 +16,13 @@ if [ ! -f "$CADDY_CONFIG" ]; then JSON fi -echo "Starting Caddy with admin API…" -caddy run --config "$CADDY_CONFIG" & +# Allow pointing at a custom Caddy build (e.g. one compiled with extra plugins) +# mounted into the container. Keep this in sync with CPM_CADDY_BINARY so CPM's +# module detection inspects the same binary. +CADDY_BINARY="${CPM_CADDY_BINARY:-caddy}" + +echo "Starting ${CADDY_BINARY} with admin API…" +"$CADDY_BINARY" run --config "$CADDY_CONFIG" & CADDY_PID=$! # Stop Caddy when CPM exits. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6277975..495ab27 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -29,15 +29,48 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.5", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^3.2.6" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -272,6 +305,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -320,6 +363,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2991,6 +3149,104 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3036,6 +3292,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3102,6 +3376,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -3114,6 +3503,31 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3126,6 +3540,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3191,6 +3625,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3225,6 +3669,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -3265,6 +3736,34 @@ "dev": true, "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3272,6 +3771,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3289,6 +3802,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3298,6 +3828,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3314,6 +3854,14 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3349,6 +3897,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3367,6 +3928,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -3446,6 +4014,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3638,6 +4226,43 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -3651,6 +4276,36 @@ "node": ">= 6" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3667,6 +4322,71 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3966,6 +4686,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3985,6 +4712,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4025,6 +4763,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4070,6 +4818,43 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4119,6 +4904,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -4128,6 +4929,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/radix-ui": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.5.0.tgz", @@ -4246,6 +5057,14 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4357,6 +5176,20 @@ } } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -4402,6 +5235,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4421,6 +5281,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sonner": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", @@ -4441,6 +5308,60 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", @@ -4472,6 +5393,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4489,6 +5424,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4674,6 +5685,219 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index fd326b9..9ad0cfc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "lint": "tsc -b --noEmit" + "lint": "tsc -b --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hookform/resolvers": "^3.9.1", @@ -31,12 +33,17 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.5", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^3.2.6" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d705e67..93d6d67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Layout } from "@/components/Layout"; import { LoginPage } from "@/pages/Login"; import { OIDCCallbackPage } from "@/pages/OIDCCallback"; import { HostsPage } from "@/pages/Hosts"; +import { PluginsPage } from "@/pages/Plugins"; import type { ReactNode } from "react"; function RequireAuth({ children }: { children: ReactNode }) { @@ -27,6 +28,7 @@ function App() { } > } /> + } /> } /> diff --git a/frontend/src/components/HostDialog.test.tsx b/frontend/src/components/HostDialog.test.tsx new file mode 100644 index 0000000..40dfd12 --- /dev/null +++ b/frontend/src/components/HostDialog.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { HostDialog } from "./HostDialog"; + +// HostPluginsSection fetches on mount; stub it out so this test focuses on the +// host form payload. +vi.mock("./HostPluginsSection", () => ({ + HostPluginsSection: () => null, +})); + +describe("HostDialog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("submits joined domains and upstreams for a new host", async () => { + const onSubmit = vi.fn(); + render( + {}} + host={null} + submitting={false} + onSubmit={onSubmit} + /> + ); + + const domainInputs = screen.getAllByPlaceholderText("example.com"); + fireEvent.change(domainInputs[0], { target: { value: "a.com" } }); + + const upstreamInputs = screen.getAllByPlaceholderText("127.0.0.1:8080"); + fireEvent.change(upstreamInputs[0], { target: { value: "127.0.0.1:9000" } }); + + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith({ + matcher: "", + domains: "a.com", + upstreams: [{ backend: "127.0.0.1:9000" }], + }); + }); + + it("requires at least one domain and upstream", async () => { + const onSubmit = vi.fn(); + render( + {}} + host={null} + submitting={false} + onSubmit={onSubmit} + /> + ); + // Submit with empty fields -> zod validation blocks submission. + fireEvent.click(screen.getByRole("button", { name: /^save$/i })); + await waitFor(() => + expect(screen.getByText(/at least one valid domain/i)).toBeInTheDocument() + ); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/HostDialog.tsx b/frontend/src/components/HostDialog.tsx index 84a7f43..330ebe7 100644 --- a/frontend/src/components/HostDialog.tsx +++ b/frontend/src/components/HostDialog.tsx @@ -14,6 +14,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { HostPluginsSection } from "@/components/HostPluginsSection"; import type { Host } from "@/lib/types"; const schema = z.object({ @@ -183,6 +184,8 @@ export function HostDialog({ + + {host && } ); diff --git a/frontend/src/components/HostPluginsSection.tsx b/frontend/src/components/HostPluginsSection.tsx new file mode 100644 index 0000000..4d25878 --- /dev/null +++ b/frontend/src/components/HostPluginsSection.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + SchemaFields, + defaultFieldValues, + type FieldValues, +} from "@/components/SchemaFields"; +import { api, unwrap, type ApiEnvelope } from "@/lib/api"; +import type { + CaddyModulesResponse, + Host, + HostPlugin, + PluginSchema, +} from "@/lib/types"; + +interface HostPluginsSectionProps { + host: Host; +} + +// HostPluginsSection lets the user configure per-host plugins (e.g. basic +// auth) inside the host editor. It only offers plugins whose module is present +// in the running Caddy build and that declare host scope. +export function HostPluginsSection({ host }: HostPluginsSectionProps) { + const [schemas, setSchemas] = useState([]); + const [available, setAvailable] = useState>(new Set()); + const [configured, setConfigured] = useState(host.Plugins ?? []); + const [selected, setSelected] = useState(""); + const [values, setValues] = useState({}); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + try { + const [schemaRes, modRes] = await Promise.all([ + api.get>("/caddy/host-schemas"), + api.get>("/caddy/modules"), + ]); + setSchemas(unwrap(schemaRes.data) ?? []); + const mods = unwrap(modRes.data); + const ids = new Set( + (mods?.modules ?? []).map((m) => m.id) + ); + setAvailable(ids); + } catch { + // Non-fatal: section simply shows nothing configurable. + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + // Schemas usable for this host: host-scoped AND present in the build. + const usable = useMemo( + () => schemas.filter((s) => available.has(s.moduleId)), + [schemas, available] + ); + + const selectedSchema = usable.find((s) => s.moduleId === selected) ?? null; + + useEffect(() => { + if (selectedSchema) setValues(defaultFieldValues(selectedSchema.fields)); + }, [selectedSchema]); + + const refreshConfigured = useCallback(async () => { + try { + const res = await api.get>(`/hosts/${host.ID}`); + setConfigured(unwrap(res.data)?.Plugins ?? []); + } catch { + /* ignore */ + } + }, [host.ID]); + + const save = async () => { + if (!selectedSchema) return; + setSaving(true); + try { + await api.put(`/hosts/${host.ID}/plugins`, { + moduleId: selectedSchema.moduleId, + values, + }); + toast.success("Plugin configured"); + setSelected(""); + refreshConfigured(); + } catch (err) { + const msg = + (err as { response?: { data?: { error?: { message?: string } } } }) + .response?.data?.error?.message ?? "Failed to configure plugin"; + toast.error(msg); + } finally { + setSaving(false); + } + }; + + const remove = async (pluginID: number) => { + try { + await api.delete(`/hosts/${host.ID}/plugins/${pluginID}`); + toast.success("Plugin removed"); + refreshConfigured(); + } catch { + toast.error("Failed to remove plugin"); + } + }; + + if (usable.length === 0) { + return ( +
+

Plugins

+

+ No host-configurable plugins are available in this Caddy build. +

+
+ ); + } + + return ( +
+

Plugins

+ + {configured.length > 0 && ( +
+ {configured.map((p) => ( +
+ + {schemas.find((s) => s.moduleId === p.moduleId)?.title ?? + p.moduleId} + {!p.enabled && " (disabled)"} + + +
+ ))} +
+ )} + +
+ + + {selectedSchema && ( +
+ {selectedSchema.description && ( +

+ {selectedSchema.description} +

+ )} + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 537b3c7..3353cd7 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,8 +1,14 @@ -import { Outlet, useNavigate } from "react-router-dom"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { LogOut, Server } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; import { useAuth } from "@/lib/auth"; +const navItems = [ + { to: "/", label: "Hosts", end: true }, + { to: "/plugins", label: "Plugins", end: false }, +]; + export function Layout() { const { logout } = useAuth(); const navigate = useNavigate(); @@ -11,9 +17,30 @@ export function Layout() {
-
- - Caddy Proxy Manager +
+
+ + Caddy Proxy Manager +
+