mirror of
https://github.com/Pacerino/CaddyProxyManager.git
synced 2026-07-22 05:31:41 -04:00
Merge pull request #8 from Pacerino/feature/host-plugins
Feature/host plugins
This commit is contained in:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -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
145
.github/workflows/pr-e2e.yml
vendored
Normal 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.",
|
||||
});
|
||||
13
Dockerfile
13
Dockerfile
@@ -28,7 +28,18 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
|
||||
# ---- Runtime ----
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates caddy
|
||||
# Install Caddy from the official static release rather than the Alpine package,
|
||||
# which can lag behind (older builds lack `list-modules --json`, used by CPM to
|
||||
# detect plugins). Pin the version here; TARGETARCH is provided by buildx.
|
||||
ARG CADDY_VERSION=2.11.4
|
||||
ARG TARGETARCH=amd64
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& wget -qO /tmp/caddy.tar.gz \
|
||||
"https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_${TARGETARCH}.tar.gz" \
|
||||
&& tar -xzf /tmp/caddy.tar.gz -C /usr/local/bin caddy \
|
||||
&& rm /tmp/caddy.tar.gz \
|
||||
&& chmod +x /usr/local/bin/caddy \
|
||||
&& caddy version
|
||||
COPY --from=backend /out/cpm /usr/local/bin/cpm
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -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}}
|
||||
|
||||
80
backend/internal/api/handler/auth_test.go
Normal file
80
backend/internal/api/handler/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
236
backend/internal/api/handler/caddy.go
Normal file
236
backend/internal/api/handler/caddy.go
Normal 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
|
||||
}
|
||||
164
backend/internal/api/handler/caddy_endpoints_test.go
Normal file
164
backend/internal/api/handler/caddy_endpoints_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
141
backend/internal/api/handler/hostplugins.go
Normal file
141
backend/internal/api/handler/hostplugins.go
Normal 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) },
|
||||
})
|
||||
}
|
||||
162
backend/internal/api/handler/hostplugins_test.go
Normal file
162
backend/internal/api/handler/hostplugins_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
172
backend/internal/api/handler/hosts_test.go
Normal file
172
backend/internal/api/handler/hosts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
65
backend/internal/api/handler/login_test.go
Normal file
65
backend/internal/api/handler/login_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
116
backend/internal/api/handler/moduleconfig_test.go
Normal file
116
backend/internal/api/handler/moduleconfig_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
134
backend/internal/api/handler/testmain_test.go
Normal file
134
backend/internal/api/handler/testmain_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
116
backend/internal/api/handler/user_test.go
Normal file
116
backend/internal/api/handler/user_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
91
backend/internal/api/http/http_test.go
Normal file
91
backend/internal/api/http/http_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
64
backend/internal/auth/auth_test.go
Normal file
64
backend/internal/auth/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
88
backend/internal/auth/keys_jwt_test.go
Normal file
88
backend/internal/auth/keys_jwt_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
178
backend/internal/caddy/apiconfig_test.go
Normal file
178
backend/internal/caddy/apiconfig_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
146
backend/internal/caddy/caddyfile_test.go
Normal file
146
backend/internal/caddy/caddyfile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
117
backend/internal/caddy/listmodules_test.go
Normal file
117
backend/internal/caddy/listmodules_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
49
backend/internal/caddy/moduleconfig.go
Normal file
49
backend/internal/caddy/moduleconfig.go
Normal 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
|
||||
}
|
||||
185
backend/internal/caddy/modules.go
Normal file
185
backend/internal/caddy/modules.go
Normal 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:]
|
||||
}
|
||||
60
backend/internal/caddy/modules_test.go
Normal file
60
backend/internal/caddy/modules_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
92
backend/internal/caddy/reload_test.go
Normal file
92
backend/internal/caddy/reload_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
61
backend/internal/caddy/schema/basicauth.go
Normal file
61
backend/internal/caddy/schema/basicauth.go
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
39
backend/internal/caddy/schema/cloudflare.go
Normal file
39
backend/internal/caddy/schema/cloudflare.go
Normal 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
|
||||
},
|
||||
})
|
||||
}
|
||||
196
backend/internal/caddy/schema/schema.go
Normal file
196
backend/internal/caddy/schema/schema.go
Normal 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 ""
|
||||
}
|
||||
142
backend/internal/caddy/schema/schema_test.go
Normal file
142
backend/internal/caddy/schema/schema_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
81
backend/internal/database/models_test.go
Normal file
81
backend/internal/database/models_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
86
backend/internal/jobqueue/jobqueue_test.go
Normal file
86
backend/internal/jobqueue/jobqueue_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
31
backend/internal/util/interfaces_test.go
Normal file
31
backend/internal/util/interfaces_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
57
backend/internal/util/slices_test.go
Normal file
57
backend/internal/util/slices_test.go
Normal 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
43
docker-compose.e2e.yml
Normal 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}
|
||||
@@ -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.
|
||||
|
||||
1226
frontend/package-lock.json
generated
1226
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
frontend/src/components/HostDialog.test.tsx
Normal file
60
frontend/src/components/HostDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
181
frontend/src/components/HostPluginsSection.tsx
Normal file
181
frontend/src/components/HostPluginsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
112
frontend/src/components/PluginDialog.test.tsx
Normal file
112
frontend/src/components/PluginDialog.test.tsx
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
165
frontend/src/components/PluginDialog.tsx
Normal file
165
frontend/src/components/PluginDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/SchemaFields.test.tsx
Normal file
50
frontend/src/components/SchemaFields.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
71
frontend/src/components/SchemaFields.tsx
Normal file
71
frontend/src/components/SchemaFields.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
frontend/src/lib/api.test.ts
Normal file
14
frontend/src/lib/api.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
207
frontend/src/pages/Plugins.tsx
Normal file
207
frontend/src/pages/Plugins.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
frontend/src/test/setup.ts
Normal file
7
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -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
108
test/e2e/run.sh
Executable 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"
|
||||
Reference in New Issue
Block a user