Merge pull request #8 from Pacerino/feature/host-plugins

Feature/host plugins
This commit is contained in:
Alexander
2026-06-15 23:53:44 +02:00
committed by GitHub
60 changed files with 5951 additions and 39 deletions

View File

@@ -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

145
.github/workflows/pr-e2e.yml vendored Normal file
View File

@@ -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 = "<!-- cpm-e2e-env -->";
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.",
});

View File

@@ -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

View File

@@ -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}}

View File

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

View File

@@ -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
}

View File

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

View File

@@ -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) },
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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")
}
}

View File

@@ -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))

View File

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

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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"])
}
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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 <args...>` 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:]
}

View File

@@ -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])
}
}
}

View File

@@ -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")
}
}

View File

@@ -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
},
})
}

View File

@@ -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
},
})
}

View File

@@ -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 ""
}

View File

@@ -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")
}
}

View File

@@ -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"`

View File

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

View File

@@ -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()
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

43
docker-compose.e2e.yml Normal file
View File

@@ -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-<N> 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}

View File

@@ -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.

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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() {
}
>
<Route path="/" element={<HostsPage />} />
<Route path="/plugins" element={<PluginsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -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(
<HostDialog
open
onOpenChange={() => {}}
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(
<HostDialog
open
onOpenChange={() => {}}
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();
});
});

View File

@@ -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({
</Button>
</DialogFooter>
</form>
{host && <HostPluginsSection key={host.ID} host={host} />}
</DialogContent>
</Dialog>
);

View File

@@ -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<PluginSchema[]>([]);
const [available, setAvailable] = useState<Set<string>>(new Set());
const [configured, setConfigured] = useState<HostPlugin[]>(host.Plugins ?? []);
const [selected, setSelected] = useState<string>("");
const [values, setValues] = useState<FieldValues>({});
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const [schemaRes, modRes] = await Promise.all([
api.get<ApiEnvelope<PluginSchema[]>>("/caddy/host-schemas"),
api.get<ApiEnvelope<CaddyModulesResponse>>("/caddy/modules"),
]);
setSchemas(unwrap(schemaRes.data) ?? []);
const mods = unwrap(modRes.data);
const ids = new Set<string>(
(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<ApiEnvelope<Host>>(`/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 (
<div className="space-y-2 border-t border-border pt-4">
<p className="text-sm font-medium">Plugins</p>
<p className="text-xs text-muted-foreground">
No host-configurable plugins are available in this Caddy build.
</p>
</div>
);
}
return (
<div className="space-y-3 border-t border-border pt-4">
<p className="text-sm font-medium">Plugins</p>
{configured.length > 0 && (
<div className="space-y-1">
{configured.map((p) => (
<div
key={p.ID}
className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-1.5 text-sm"
>
<span>
{schemas.find((s) => s.moduleId === p.moduleId)?.title ??
p.moduleId}
{!p.enabled && " (disabled)"}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(p.ID)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
)}
<div className="space-y-2">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
<option value="">Add a plugin</option>
{usable.map((s) => (
<option key={s.moduleId} value={s.moduleId}>
{s.title}
</option>
))}
</select>
{selectedSchema && (
<div className="space-y-4 rounded-md border border-border p-3">
{selectedSchema.description && (
<p className="text-xs text-muted-foreground">
{selectedSchema.description}
</p>
)}
<SchemaFields
fields={selectedSchema.fields}
values={values}
onChange={setValues}
idPrefix="hostplugin-"
/>
<Button type="button" size="sm" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save plugin"}
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -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() {
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="text-lg font-semibold">Caddy Proxy Manager</span>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="text-lg font-semibold">Caddy Proxy Manager</span>
</div>
<nav className="flex items-center gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
isActive
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
)
}
>
{item.label}
</NavLink>
))}
</nav>
</div>
<Button
variant="ghost"

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PluginDialog } from "./PluginDialog";
import type { CaddyModule, PluginSchema } from "@/lib/types";
const cfModule: CaddyModule = {
id: "dns.providers.cloudflare",
namespace: "dns.providers",
name: "cloudflare",
standard: false,
};
const cfSchema: PluginSchema = {
moduleId: "dns.providers.cloudflare",
title: "Cloudflare DNS",
scopes: ["global"],
path: "apps/tls/automation/policies",
fields: [{ key: "api_token", label: "API Token", type: "secret", required: true }],
};
const rawModule: CaddyModule = {
id: "http.handlers.unknown",
namespace: "http.handlers",
name: "unknown",
standard: false,
};
describe("PluginDialog typed form", () => {
it("submits schema values", () => {
const onSubmit = vi.fn();
render(
<PluginDialog
open
onOpenChange={() => {}}
module={cfModule}
schema={cfSchema}
existing={null}
submitting={false}
onSubmit={onSubmit}
/>
);
fireEvent.change(screen.getByLabelText(/API Token/i), {
target: { value: "tok123" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({
moduleId: "dns.providers.cloudflare",
enabled: true,
values: { api_token: "tok123" },
});
});
});
describe("PluginDialog raw JSON fallback", () => {
it("rejects invalid JSON", () => {
const onSubmit = vi.fn();
render(
<PluginDialog
open
onOpenChange={() => {}}
module={rawModule}
schema={null}
existing={null}
submitting={false}
onSubmit={onSubmit}
/>
);
fireEvent.change(screen.getByLabelText(/Config path/i), {
target: { value: "apps/http/servers/srv0" },
});
fireEvent.change(screen.getByLabelText(/Config \(JSON\)/i), {
target: { value: "{ not json" },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.getByText(/must be valid JSON/i)).toBeInTheDocument();
});
it("submits valid raw config with path", () => {
const onSubmit = vi.fn();
render(
<PluginDialog
open
onOpenChange={() => {}}
module={rawModule}
schema={null}
existing={null}
submitting={false}
onSubmit={onSubmit}
/>
);
fireEvent.change(screen.getByLabelText(/Config path/i), {
target: { value: "apps/http/servers/srv0" },
});
fireEvent.change(screen.getByLabelText(/Config \(JSON\)/i), {
target: { value: '{"foo":"bar"}' },
});
fireEvent.click(screen.getByRole("button", { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith({
moduleId: "http.handlers.unknown",
enabled: true,
path: "apps/http/servers/srv0",
config: { foo: "bar" },
});
});
});

View File

@@ -0,0 +1,165 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
SchemaFields,
defaultFieldValues,
type FieldValues,
} from "@/components/SchemaFields";
import type { CaddyModule, ModuleConfig, PluginSchema } from "@/lib/types";
// Payload sent to PUT /caddy/module-configs.
export interface ModuleConfigPayload {
moduleId: string;
path?: string;
enabled: boolean;
// Either typed form values (when a schema exists) or a raw JSON config.
values?: Record<string, unknown>;
config?: unknown;
}
interface PluginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
module: CaddyModule | null;
schema: PluginSchema | null;
existing: ModuleConfig | null;
submitting: boolean;
onSubmit: (payload: ModuleConfigPayload) => void;
}
export function PluginDialog({
open,
onOpenChange,
module,
schema,
existing,
submitting,
onSubmit,
}: PluginDialogProps) {
const [values, setValues] = useState<FieldValues>({});
const [rawConfig, setRawConfig] = useState("{}");
const [rawPath, setRawPath] = useState("");
const [enabled, setEnabled] = useState(true);
const [jsonError, setJsonError] = useState<string | null>(null);
const moduleId = module?.id ?? existing?.moduleId ?? "";
// Seed the form whenever the dialog opens for a (different) module.
useEffect(() => {
if (!open) return;
setEnabled(existing?.enabled ?? true);
setJsonError(null);
if (schema) {
// Secrets are never returned by the API, so they start blank.
setValues(defaultFieldValues(schema.fields));
} else {
setRawPath(existing?.path ?? "");
setRawConfig(
existing?.config != null
? JSON.stringify(existing.config, null, 2)
: "{}"
);
}
}, [open, schema, existing]);
const title = useMemo(() => {
if (schema) return `Configure ${schema.title}`;
return `Configure ${moduleId}`;
}, [schema, moduleId]);
const submit = () => {
if (schema) {
onSubmit({ moduleId, enabled, values });
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(rawConfig);
} catch {
setJsonError("Config must be valid JSON");
return;
}
if (!rawPath.trim()) {
setJsonError("A config path is required");
return;
}
onSubmit({ moduleId, enabled, path: rawPath.trim(), config: parsed });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{schema?.description ??
"No typed schema is available for this module — provide a raw JSON config fragment that will be applied to Caddy via its admin API."}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
{schema ? (
<SchemaFields
fields={schema.fields}
values={values}
onChange={setValues}
/>
) : (
<>
<div className="space-y-2">
<Label htmlFor="config-path">Config path</Label>
<Input
id="config-path"
placeholder="apps/http/servers/srv0"
value={rawPath}
onChange={(e) => setRawPath(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Path within Caddy's config (relative to /config/).
</p>
</div>
<div className="space-y-2">
<Label htmlFor="config-json">Config (JSON)</Label>
<textarea
id="config-json"
className="flex min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={rawConfig}
onChange={(e) => setRawConfig(e.target.value)}
spellCheck={false}
/>
</div>
</>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
Enabled
</label>
{jsonError && <p className="text-sm text-destructive">{jsonError}</p>}
</div>
<DialogFooter>
<Button type="button" onClick={submit} disabled={submitting}>
{submitting ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import {
SchemaFields,
defaultFieldValues,
type FieldValues,
} from "./SchemaFields";
import type { SchemaField } from "@/lib/types";
const fields: SchemaField[] = [
{ key: "username", label: "Username", type: "string", required: true },
{ key: "password", label: "Password", type: "secret", required: true },
{ key: "enabled", label: "Enabled", type: "bool", default: true },
];
describe("defaultFieldValues", () => {
it("seeds strings empty and bools from default", () => {
const v = defaultFieldValues(fields);
expect(v.username).toBe("");
expect(v.password).toBe("");
expect(v.enabled).toBe(true);
});
});
describe("SchemaFields", () => {
it("renders inputs with correct types and required markers", () => {
render(
<SchemaFields fields={fields} values={defaultFieldValues(fields)} onChange={() => {}} />
);
// secret -> password input
const pw = screen.getByLabelText(/Password/i) as HTMLInputElement;
expect(pw.type).toBe("password");
// bool -> checkbox
expect(screen.getByRole("checkbox")).toBeInTheDocument();
});
it("emits changes for text and checkbox fields", () => {
const onChange = vi.fn();
const values: FieldValues = defaultFieldValues(fields);
render(<SchemaFields fields={fields} values={values} onChange={onChange} />);
fireEvent.change(screen.getByLabelText(/Username/i), {
target: { value: "bob" },
});
expect(onChange).toHaveBeenLastCalledWith({ ...values, username: "bob" });
fireEvent.click(screen.getByRole("checkbox"));
expect(onChange).toHaveBeenLastCalledWith({ ...values, enabled: false });
});
});

View File

@@ -0,0 +1,71 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { SchemaField } from "@/lib/types";
export type FieldValues = Record<string, string | boolean>;
export function defaultFieldValues(fields: SchemaField[]): FieldValues {
const seeded: FieldValues = {};
for (const f of fields) {
if (f.type === "bool") seeded[f.key] = Boolean(f.default ?? false);
else seeded[f.key] = f.default != null ? String(f.default) : "";
}
return seeded;
}
interface SchemaFieldsProps {
fields: SchemaField[];
values: FieldValues;
onChange: (values: FieldValues) => void;
idPrefix?: string;
}
// SchemaFields renders typed inputs for a plugin schema's fields. Shared by
// the global Plugins dialog and the per-host plugin editor.
export function SchemaFields({
fields,
values,
onChange,
idPrefix = "",
}: SchemaFieldsProps) {
const set = (key: string, value: string | boolean) =>
onChange({ ...values, [key]: value });
return (
<>
{fields.map((field) => {
const id = `${idPrefix}${field.key}`;
if (field.type === "bool") {
return (
<label key={field.key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={Boolean(values[field.key])}
onChange={(e) => set(field.key, e.target.checked)}
/>
{field.label}
</label>
);
}
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={id}>
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
<Input
id={id}
type={field.type === "secret" ? "password" : "text"}
placeholder={field.placeholder}
value={String(values[field.key] ?? "")}
onChange={(e) => set(field.key, e.target.value)}
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
);
})}
</>
);
}

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest";
import { unwrap, type ApiEnvelope } from "./api";
describe("unwrap", () => {
it("returns the result field", () => {
const env: ApiEnvelope<{ a: number }> = { result: { a: 1 } };
expect(unwrap(env)).toEqual({ a: 1 });
});
it("passes arrays through", () => {
const env: ApiEnvelope<number[]> = { result: [1, 2, 3] };
expect(unwrap(env)).toEqual([1, 2, 3]);
});
});

View File

@@ -4,6 +4,14 @@ export interface Upstream {
backend: string;
}
export interface HostPlugin {
ID: number;
hostId: number;
moduleId: string;
handler: unknown;
enabled: boolean;
}
export interface Host {
ID: number;
CreatedAt: string;
@@ -11,6 +19,7 @@ export interface Host {
domains: string;
matcher: string;
Upstreams: Upstream[];
Plugins?: HostPlugin[];
}
export type AuthMode = "local" | "oidc";
@@ -18,3 +27,53 @@ export type AuthMode = "local" | "oidc";
export interface AuthConfig {
mode: AuthMode;
}
// CaddyModule is a single module reported by `caddy list-modules`.
export interface CaddyModule {
id: string;
namespace: string;
name: string;
standard: boolean;
version?: string;
package?: string;
}
export interface CaddyModulesResponse {
modules: CaddyModule[];
plugins: CaddyModule[];
}
export type SchemaFieldType = "string" | "int" | "bool" | "secret";
export interface SchemaField {
key: string;
label: string;
description?: string;
type: SchemaFieldType;
required?: boolean;
default?: unknown;
placeholder?: string;
}
export type PluginScope = "global" | "host";
// PluginSchema is a typed configuration descriptor for a known plugin.
export interface PluginSchema {
moduleId: string;
title: string;
description?: string;
scopes: PluginScope[];
path?: string;
fields: SchemaField[];
}
// ModuleConfig is a stored configuration fragment for a Caddy module.
export interface ModuleConfig {
ID: number;
CreatedAt: string;
UpdatedAt: string;
moduleId: string;
path: string;
config: unknown;
enabled: boolean;
}

View File

@@ -0,0 +1,207 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Settings2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api, unwrap, type ApiEnvelope } from "@/lib/api";
import type {
CaddyModule,
CaddyModulesResponse,
ModuleConfig,
PluginSchema,
} from "@/lib/types";
import {
PluginDialog,
type ModuleConfigPayload,
} from "@/components/PluginDialog";
export function PluginsPage() {
const [plugins, setPlugins] = useState<CaddyModule[]>([]);
const [schemas, setSchemas] = useState<PluginSchema[]>([]);
const [configs, setConfigs] = useState<ModuleConfig[]>([]);
const [loading, setLoading] = useState(true);
const [unavailable, setUnavailable] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selected, setSelected] = useState<CaddyModule | null>(null);
const schemaByModule = useMemo(() => {
const map = new Map<string, PluginSchema>();
for (const s of schemas) map.set(s.moduleId, s);
return map;
}, [schemas]);
const configByModule = useMemo(() => {
const map = new Map<string, ModuleConfig>();
for (const c of configs) map.set(c.moduleId, c);
return map;
}, [configs]);
const load = useCallback(async () => {
setLoading(true);
try {
const [mods, schemaRes] = await Promise.all([
api.get<ApiEnvelope<CaddyModulesResponse>>("/caddy/modules"),
api.get<ApiEnvelope<PluginSchema[]>>("/caddy/schemas"),
]);
setPlugins(unwrap(mods.data)?.plugins ?? []);
setSchemas(unwrap(schemaRes.data) ?? []);
} catch {
toast.error("Failed to detect Caddy modules");
}
// Module configs require API mode; treat a failure as "unavailable".
try {
const cfg = await api.get<ApiEnvelope<ModuleConfig[]>>(
"/caddy/module-configs"
);
setConfigs(unwrap(cfg.data) ?? []);
setUnavailable(false);
} catch {
setConfigs([]);
setUnavailable(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const openConfigure = (module: CaddyModule) => {
setSelected(module);
setDialogOpen(true);
};
const saveConfig = async (payload: ModuleConfigPayload) => {
setSubmitting(true);
try {
await api.put("/caddy/module-configs", payload);
toast.success("Module configuration saved");
setDialogOpen(false);
load();
} catch (err) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
.response?.data?.error?.message ?? "Failed to save configuration";
toast.error(msg);
} finally {
setSubmitting(false);
}
};
const deleteConfig = async (id: number) => {
try {
await api.delete(`/caddy/module-configs/${id}`);
toast.success("Configuration removed");
load();
} catch {
toast.error("Failed to remove configuration");
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Plugins</h1>
<p className="text-sm text-muted-foreground">
Modules detected in your Caddy build. Configure global plugin settings
here; per-host plugins (e.g. authentication) are set in the host editor.
</p>
</div>
{unavailable && (
<div className="rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
Module configuration requires Caddy API mode (CPM_CADDY_MODE=api).
Detected plugins are shown read-only.
</div>
)}
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Module</TableHead>
<TableHead>Package</TableHead>
<TableHead className="w-28">Configurable</TableHead>
<TableHead className="w-24">Status</TableHead>
<TableHead className="w-28 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Loading
</TableCell>
</TableRow>
) : plugins.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No plugins detected in this Caddy build.
</TableCell>
</TableRow>
) : (
plugins.map((module) => {
const cfg = configByModule.get(module.id);
const typed = schemaByModule.has(module.id);
return (
<TableRow key={module.id}>
<TableCell className="font-medium">{module.id}</TableCell>
<TableCell className="text-muted-foreground">
{module.package || "—"}
</TableCell>
<TableCell>{typed ? "Form" : "Raw JSON"}</TableCell>
<TableCell>
{cfg ? (cfg.enabled ? "Enabled" : "Disabled") : "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
disabled={unavailable}
onClick={() => openConfigure(module)}
>
<Settings2 className="size-4" />
</Button>
{cfg && (
<Button
variant="ghost"
size="icon"
disabled={unavailable}
onClick={() => deleteConfig(cfg.ID)}
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<PluginDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
module={selected}
schema={selected ? schemaByModule.get(selected.id) ?? null : null}
existing={selected ? configByModule.get(selected.id) ?? null : null}
submitting={submitting}
onSubmit={saveConfig}
/>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
});

View File

@@ -1,5 +1,5 @@
import path from "node:path";
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -11,6 +11,12 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
css: false,
},
build: {
// Build straight into the Go embed directory so the binary picks it up.
outDir: "../backend/embed/assets",

108
test/e2e/run.sh Executable file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# End-to-end test for a deployed CPM PR environment running in API mode.
#
# Verifies the full stack: API health, admin login, host creation, that Caddy
# actually reverse-proxies a request to the whoami upstream, the per-host basic
# auth plugin, and module discovery. Hits the containers locally on the runner
# (independent of any tunnel).
#
# Env:
# CPM_URL base URL of the CPM API (default http://localhost:3001)
# CADDY_URL base URL of Caddy's HTTP port (default http://localhost:8080)
# ADMIN_EMAIL seeded admin email (default admin@example.com)
# ADMIN_PASSWORD seeded admin password (default changeme)
# TEST_DOMAIN host header used for the proxied request (default app.e2e.local)
set -euo pipefail
CPM_URL="${CPM_URL:-http://localhost:3001}"
CADDY_URL="${CADDY_URL:-http://localhost:8080}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-changeme}"
TEST_DOMAIN="${TEST_DOMAIN:-app.e2e.local}"
pass() { echo "PASS: $1"; }
fail() { echo "FAIL: $1" >&2; exit 1; }
# jqr <json> <filter> — extract a field with jq.
jqr() { printf '%s' "$1" | jq -r "$2"; }
echo "== Waiting for CPM API to become healthy =="
for i in $(seq 1 30); do
if curl -fsS "${CPM_URL}/api/auth/config" >/dev/null 2>&1; then
break
fi
if [ "$i" = "30" ]; then fail "CPM API did not become healthy in time"; fi
sleep 2
done
pass "CPM API is up"
echo "== Auth config reports api/local mode =="
cfg=$(curl -fsS "${CPM_URL}/api/auth/config")
mode=$(jqr "$cfg" '.result.mode')
[ "$mode" = "local" ] || fail "expected local auth mode, got '$mode'"
pass "auth mode = local"
echo "== Admin login =="
login=$(curl -fsS -X POST "${CPM_URL}/api/users/login" \
-H 'Content-Type: application/json' \
-d "{\"Email\":\"${ADMIN_EMAIL}\",\"Secret\":\"${ADMIN_PASSWORD}\"}")
TOKEN=$(jqr "$login" '.result.token')
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || fail "login did not return a token: $login"
AUTH="Authorization: Bearer ${TOKEN}"
pass "logged in, got JWT"
echo "== Module discovery (list-modules in-container) =="
mods=$(curl -fsS -H "$AUTH" "${CPM_URL}/api/caddy/modules")
mcount=$(jqr "$mods" '.result.modules | length')
[ "$mcount" -gt 0 ] || fail "expected modules from caddy build, got $mcount"
pass "discovered $mcount caddy modules"
echo "== Create a host that proxies ${TEST_DOMAIN} -> whoami =="
created=$(curl -fsS -X POST "${CPM_URL}/api/hosts" \
-H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"domains\":\"${TEST_DOMAIN}\",\"matcher\":\"\",\"Upstreams\":[{\"backend\":\"whoami:80\"}]}")
HOST_ID=$(jqr "$created" '.result.ID')
[ -n "$HOST_ID" ] && [ "$HOST_ID" != "null" ] || fail "host create failed: $created"
pass "created host id=$HOST_ID"
echo "== Verify Caddy reverse-proxies the request to whoami =="
# CPM applies the route asynchronously via the job queue; allow a short window.
ok=""
for i in $(seq 1 15); do
body=$(curl -fsS -H "Host: ${TEST_DOMAIN}" "${CADDY_URL}/" 2>/dev/null || true)
# whoami echoes a "Hostname:" line in its response.
if printf '%s' "$body" | grep -qi "Hostname:"; then
ok="yes"; break
fi
sleep 2
done
[ -n "$ok" ] || fail "proxied request did not reach whoami"
pass "request proxied to whoami"
echo "== Add basic auth plugin to the host =="
curl -fsS -X PUT "${CPM_URL}/api/hosts/${HOST_ID}/plugins" \
-H "$AUTH" -H 'Content-Type: application/json' \
-d '{"moduleId":"http.handlers.authentication","values":{"username":"e2e","password":"e2epass"}}' >/dev/null
pass "basic auth plugin configured"
echo "== Request without credentials should be 401 =="
ok=""
for i in $(seq 1 15); do
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${CADDY_URL}/" || true)
if [ "$code" = "401" ]; then ok="yes"; break; fi
sleep 2
done
[ -n "$ok" ] || fail "expected 401 without credentials"
pass "unauthenticated request rejected (401)"
echo "== Request with credentials should be 200 =="
code=$(curl -s -o /dev/null -w '%{http_code}' -u "e2e:e2epass" -H "Host: ${TEST_DOMAIN}" "${CADDY_URL}/" || true)
[ "$code" = "200" ] || fail "expected 200 with credentials, got $code"
pass "authenticated request succeeded (200)"
echo "== Delete the host =="
del=$(curl -fsS -X DELETE -H "$AUTH" "${CPM_URL}/api/hosts/${HOST_ID}")
pass "host deleted"
echo
echo "ALL E2E CHECKS PASSED"