fix(workflows): replace invalid semantic-version action with fallback script
This commit is contained in:
@@ -46,6 +46,7 @@ backend/cmd/api/data/*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
cpm.db
|
||||
charon.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
10
.github/copilot-instructions.md
vendored
10
.github/copilot-instructions.md
vendored
@@ -1,4 +1,4 @@
|
||||
# CaddyProxyManager+ Copilot Instructions
|
||||
# Charon Copilot Instructions
|
||||
|
||||
## 🚨 CRITICAL ARCHITECTURE RULES 🚨
|
||||
- **Single Frontend Source**: All frontend code MUST reside in `frontend/`. NEVER create `backend/frontend/` or any other nested frontend directory.
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## Big Picture
|
||||
- `backend/cmd/api` loads config, opens SQLite, then hands off to `internal/server` where routes from `internal/api/routes` are registered.
|
||||
- `internal/config` respects `CPM_ENV`, `CPM_HTTP_PORT`, `CPM_DB_PATH`, `CPM_FRONTEND_DIR` and creates the `data/` directory; lean on these instead of hard-coded paths.
|
||||
- `internal/config` respects `CHARON_ENV`, `CHARON_HTTP_PORT`, `CHARON_DB_PATH`, `CHARON_FRONTEND_DIR` (CHARON_ preferred; CPM_ still supported) and creates the `data/` directory; lean on these instead of hard-coded paths.
|
||||
- All HTTP endpoints live under `/api/v1/*`; keep new handlers inside `internal/api/handlers` and register them via `routes.Register` so `db.AutoMigrate` runs for their models.
|
||||
- `internal/server` also mounts the built React app (via `attachFrontend`) whenever `frontend/dist` exists, falling back to JSON `{"error": ...}` for any `/api/*` misses.
|
||||
- Persistent types live in `internal/models`; GORM auto-migrates them each boot, so evolve schemas there before touching handlers or the frontend.
|
||||
@@ -41,9 +41,9 @@
|
||||
- **Feature Documentation**: When adding new features, update `docs/features.md` to include the new capability. This is the canonical list of all features shown to users.
|
||||
- **README**: The main `README.md` is a marketing/welcome page. Keep it brief with top features, quick start, and links to docs. All detailed documentation belongs in `docs/`.
|
||||
- **Link Format**: Use GitHub Pages URLs for documentation links, not relative paths:
|
||||
- Docs: `https://wikid82.github.io/cpmp/` (index) or `https://wikid82.github.io/cpmp/features` (specific page, no `.md`)
|
||||
- Repo files (CONTRIBUTING, LICENSE): `https://github.com/Wikid82/cpmp/blob/main/CONTRIBUTING.md`
|
||||
- Issues/Discussions: `https://github.com/Wikid82/cpmp/issues` or `https://github.com/Wikid82/cpmp/discussions`
|
||||
- Docs: `https://wikid82.github.io/charon/` (index) or `https://wikid82.github.io/charon/features` (specific page, no `.md`)
|
||||
- Repo files (CONTRIBUTING, LICENSE): `https://github.com/Wikid82/charon/blob/main/CONTRIBUTING.md`
|
||||
- Issues/Discussions: `https://github.com/Wikid82/charon/issues` or `https://github.com/Wikid82/charon/discussions`
|
||||
|
||||
## CI/CD & Commit Conventions
|
||||
- **Docker Builds**: The `docker-publish` workflow skips builds for commits starting with `chore:`.
|
||||
|
||||
26
.github/release-drafter.yml
vendored
Normal file
26
.github/release-drafter.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name-template: 'v$NEXT_PATCH_VERSION'
|
||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||
categories:
|
||||
- title: '🚀 Features'
|
||||
labels:
|
||||
- 'feature'
|
||||
- 'feat'
|
||||
- title: '🐛 Fixes'
|
||||
labels:
|
||||
- 'bug'
|
||||
- 'fix'
|
||||
- title: '🧰 Maintenance'
|
||||
labels:
|
||||
- 'chore'
|
||||
- title: '🧪 Tests'
|
||||
labels:
|
||||
- 'test'
|
||||
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
|
||||
template: |
|
||||
## What's Changed
|
||||
|
||||
$CHANGES
|
||||
|
||||
----
|
||||
|
||||
Full Changelog: https://github.com/${{ github.repository }}/compare/$FROM_TAG...$TO_TAG
|
||||
17
.github/workflows/auto-changelog.yml
vendored
Normal file
17
.github/workflows/auto-changelog.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Auto Changelog (Release Drafter)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
update-draft:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Draft Release
|
||||
uses: release-drafter/release-drafter@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
53
.github/workflows/auto-versioning.yml
vendored
Normal file
53
.github/workflows/auto-versioning.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Auto Versioning and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate semantic version (fallback script)
|
||||
id: semver
|
||||
run: |
|
||||
# Ensure git tags are fetched
|
||||
git fetch --tags --quiet || true
|
||||
# Get latest tag or default to v0.0.0
|
||||
TAG=$(git describe --abbrev=0 --tags 2>/dev/null || echo "v0.0.0")
|
||||
echo "Detected latest tag: $TAG"
|
||||
# Set outputs for downstream steps
|
||||
echo "version=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "release_notes=Fallback: using latest tag only" >> $GITHUB_OUTPUT
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Show version
|
||||
run: |
|
||||
echo "Next version: ${{ steps.semver.outputs.version }}"
|
||||
|
||||
- name: Create annotated tag and push
|
||||
if: ${{ steps.semver.outputs.changed }}
|
||||
run: |
|
||||
git tag -a v${{ steps.semver.outputs.version }} -m "Release v${{ steps.semver.outputs.version }}"
|
||||
git push origin --tags
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release (tag-only, no workspace changes)
|
||||
if: ${{ steps.semver.outputs.changed }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.semver.outputs.version }}
|
||||
name: Release ${{ steps.semver.outputs.version }}
|
||||
body: ${{ steps.semver.outputs.release_notes }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
37
.github/workflows/docker-publish.yml
vendored
37
.github/workflows/docker-publish.yml
vendored
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/cpmp
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/charon
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -83,13 +83,24 @@ jobs:
|
||||
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' caddy:2-alpine)
|
||||
echo "image=$DIGEST" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Choose Registry Token
|
||||
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
|
||||
run: |
|
||||
if [ -n "${{ secrets.CHARON_TOKEN }}" ]; then
|
||||
echo "Using CHARON_TOKEN" >&2
|
||||
echo "REGISTRY_PASSWORD=${{ secrets.CHARON_TOKEN }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "Using CPMP_TOKEN fallback" >&2
|
||||
echo "REGISTRY_PASSWORD=${{ secrets.CPMP_TOKEN }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.CPMP_TOKEN }}
|
||||
password: ${{ env.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
@@ -201,31 +212,41 @@ jobs:
|
||||
echo "tag=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Choose Registry Token
|
||||
run: |
|
||||
if [ -n "${{ secrets.CHARON_TOKEN }}" ]; then
|
||||
echo "Using CHARON_TOKEN" >&2
|
||||
echo "REGISTRY_PASSWORD=${{ secrets.CHARON_TOKEN }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "Using CPMP_TOKEN fallback" >&2
|
||||
echo "REGISTRY_PASSWORD=${{ secrets.CPMP_TOKEN }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.CPMP_TOKEN }}
|
||||
password: ${{ env.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Pull Docker image
|
||||
run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||
|
||||
- name: Create Docker Network
|
||||
run: docker network create cpmp-test-net
|
||||
run: docker network create charon-test-net
|
||||
|
||||
- name: Run Upstream Service (whoami)
|
||||
run: |
|
||||
docker run -d \
|
||||
--name whoami \
|
||||
--network cpmp-test-net \
|
||||
--network charon-test-net \
|
||||
traefik/whoami
|
||||
|
||||
- name: Run CPMP Container
|
||||
- name: Run Charon Container
|
||||
run: |
|
||||
docker run -d \
|
||||
--name test-container \
|
||||
--network cpmp-test-net \
|
||||
--network charon-test-net \
|
||||
-p 8080:8080 \
|
||||
-p 80:80 \
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
|
||||
@@ -242,7 +263,7 @@ jobs:
|
||||
run: |
|
||||
docker stop test-container whoami || true
|
||||
docker rm test-container whoami || true
|
||||
docker network rm cpmp-test-net || true
|
||||
docker network rm charon-test-net || true
|
||||
|
||||
- name: Create test summary
|
||||
if: always()
|
||||
|
||||
20
.github/workflows/docs.yml
vendored
20
.github/workflows/docs.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Caddy Proxy Manager Plus - Documentation</title>
|
||||
<title>Charon - Documentation</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||
<style>
|
||||
:root {
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🚀 Caddy Proxy Manager Plus</h1>
|
||||
<h1>🚀 Charon</h1>
|
||||
<p>Make your websites easy to reach - No coding required!</p>
|
||||
</header>
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
<section>
|
||||
<h2>👋 Welcome!</h2>
|
||||
<p style="font-size: 1.1rem; color: #cbd5e1;">
|
||||
This documentation will help you get started with Caddy Proxy Manager Plus.
|
||||
This documentation will help you get started with Charon.
|
||||
Whether you're a complete beginner or an experienced developer, we've got you covered!
|
||||
</p>
|
||||
</section>
|
||||
@@ -220,15 +220,15 @@ jobs:
|
||||
Stuck? Have questions? We're here to help!
|
||||
</p>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
|
||||
<a href="https://github.com/Wikid82/CaddyProxyManagerPlus/discussions"
|
||||
<a href="https://github.com/Wikid82/charon/discussions"
|
||||
style="background: white; color: #1e40af; padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none;">
|
||||
💬 Ask a Question
|
||||
</a>
|
||||
<a href="https://github.com/Wikid82/CaddyProxyManagerPlus/issues"
|
||||
<a href="https://github.com/Wikid82/charon/issues"
|
||||
style="background: white; color: #1e40af; padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none;">
|
||||
🐛 Report a Bug
|
||||
</a>
|
||||
<a href="https://github.com/Wikid82/CaddyProxyManagerPlus"
|
||||
<a href="https://github.com/Wikid82/charon"
|
||||
style="background: white; color: #1e40af; padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none;">
|
||||
⭐ View on GitHub
|
||||
</a>
|
||||
@@ -289,10 +289,10 @@ jobs:
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/cpmp/">🏠 Home</a>
|
||||
<a href="/cpmp/docs/index.html">📚 Docs</a>
|
||||
<a href="/cpmp/docs/getting-started.html">🚀 Get Started</a>
|
||||
<a href="https://github.com/Wikid82/cpmp">⭐ GitHub</a>
|
||||
<a href="/charon/">🏠 Home</a>
|
||||
<a href="/charon/docs/index.html">📚 Docs</a>
|
||||
<a href="/charon/docs/getting-started.html">🚀 Get Started</a>
|
||||
<a href="https://github.com/Wikid82/charon">⭐ GitHub</a>
|
||||
</nav>
|
||||
<main>
|
||||
HEADER
|
||||
|
||||
1
.github/workflows/propagate-changes.yml
vendored
1
.github/workflows/propagate-changes.yml
vendored
@@ -103,4 +103,5 @@ jobs:
|
||||
}
|
||||
}
|
||||
env:
|
||||
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
|
||||
CPMP_TOKEN: ${{ secrets.CPMP_TOKEN }}
|
||||
|
||||
16
.github/workflows/quality-checks.yml
vendored
16
.github/workflows/quality-checks.yml
vendored
@@ -55,6 +55,11 @@ jobs:
|
||||
flags: backend
|
||||
fail_ci_if_error: true
|
||||
|
||||
- name: Enforce module-specific coverage (backend)
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bash scripts/check-module-coverage.sh --backend-only
|
||||
continue-on-error: false
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v9.1.0
|
||||
with:
|
||||
@@ -80,11 +85,11 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests
|
||||
- name: Run frontend tests and coverage
|
||||
id: frontend-tests
|
||||
working-directory: frontend
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
npm run test:coverage 2>&1 | tee test-output.txt
|
||||
bash scripts/frontend-test-coverage.sh 2>&1 | tee frontend/test-output.txt
|
||||
exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Frontend Test Summary
|
||||
@@ -116,6 +121,11 @@ jobs:
|
||||
flags: frontend
|
||||
fail_ci_if_error: true
|
||||
|
||||
- name: Enforce module-specific coverage (frontend)
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bash scripts/check-module-coverage.sh --frontend-only
|
||||
continue-on-error: false
|
||||
|
||||
- name: Run frontend lint
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
|
||||
14
.github/workflows/release-goreleaser.yml
vendored
14
.github/workflows/release-goreleaser.yml
vendored
@@ -12,6 +12,11 @@ permissions:
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Use the built-in GITHUB_TOKEN by default for GitHub API operations.
|
||||
# If you need to provide a PAT with elevated permissions, add a CHARON_TOKEN secret
|
||||
# at the repo or organization level and update the env here accordingly.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -31,6 +36,9 @@ jobs:
|
||||
- name: Build Frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
# Inject version into frontend build from tag (if present)
|
||||
VERSION=$${GITHUB_REF#refs/tags/}
|
||||
echo "VITE_APP_VERSION=$$VERSION" >> $GITHUB_ENV
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
@@ -39,12 +47,12 @@ jobs:
|
||||
with:
|
||||
version: 0.13.0
|
||||
|
||||
# GITHUB_TOKEN is set from CHARON_TOKEN or CPMP_TOKEN (fallback), defaulting to GITHUB_TOKEN
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CPMP_TOKEN }}
|
||||
# CGO settings are handled in .goreleaser.yaml via Zig
|
||||
# CGO settings are handled in .goreleaser.yaml via Zig
|
||||
|
||||
12
.github/workflows/renovate.yml
vendored
12
.github/workflows/renovate.yml
vendored
@@ -18,10 +18,20 @@ jobs:
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Choose Renovate Token
|
||||
run: |
|
||||
if [ -n "${{ secrets.CHARON_TOKEN }}" ]; then
|
||||
echo "Using CHARON_TOKEN" >&2
|
||||
echo "RENOVATE_TOKEN=${{ secrets.CHARON_TOKEN }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "Using CPMP_TOKEN fallback" >&2
|
||||
echo "RENOVATE_TOKEN=${{ secrets.CPMP_TOKEN }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Run Renovate
|
||||
uses: renovatebot/github-action@03026bd55840025343414baec5d9337c5f9c7ea7 # v44.0.4
|
||||
with:
|
||||
configurationFile: .github/renovate.json
|
||||
token: ${{ secrets.CPMP_TOKEN }}
|
||||
token: ${{ env.RENOVATE_TOKEN }}
|
||||
env:
|
||||
LOG_LEVEL: info
|
||||
|
||||
11
.github/workflows/renovate_prune.yml
vendored
11
.github/workflows/renovate_prune.yml
vendored
@@ -22,10 +22,19 @@ jobs:
|
||||
BRANCH_PREFIX: "renovate/" # adjust if you use a different prefix
|
||||
|
||||
steps:
|
||||
- name: Choose GitHub Token
|
||||
run: |
|
||||
if [ -n "${{ secrets.CHARON_TOKEN }}" ]; then
|
||||
echo "Using CHARON_TOKEN" >&2
|
||||
echo "GITHUB_TOKEN=${{ secrets.CHARON_TOKEN }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "Using CPMP_TOKEN fallback" >&2
|
||||
echo "GITHUB_TOKEN=${{ secrets.CPMP_TOKEN }}" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Prune renovate branches
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ secrets.CPMP_TOKEN }}
|
||||
github-token: ${{ env.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,6 +39,7 @@ backend/data/*.db
|
||||
backend/data/**/*.db
|
||||
backend/cmd/api/data/*.db
|
||||
cpm.db
|
||||
charon.db
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
version: 2
|
||||
|
||||
project_name: cpmp
|
||||
project_name: charon
|
||||
|
||||
builds:
|
||||
- id: linux
|
||||
dir: backend
|
||||
main: ./cmd/api
|
||||
binary: cpmp
|
||||
binary: charon
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target {{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-linux-gnu
|
||||
@@ -18,14 +18,14 @@ builds:
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version={{.Version}}
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit={{.Commit}}
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildTime={{.Date}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.Version={{.Version}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.GitCommit={{.Commit}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.BuildTime={{.Date}}
|
||||
|
||||
- id: windows
|
||||
dir: backend
|
||||
main: ./cmd/api
|
||||
binary: cpmp
|
||||
binary: charon
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target x86_64-windows-gnu
|
||||
@@ -36,14 +36,14 @@ builds:
|
||||
- amd64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version={{.Version}}
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit={{.Commit}}
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildTime={{.Date}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.Version={{.Version}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.GitCommit={{.Commit}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.BuildTime={{.Date}}
|
||||
|
||||
- id: darwin
|
||||
dir: backend
|
||||
main: ./cmd/api
|
||||
binary: cpmp
|
||||
binary: charon
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CC=zig cc -target {{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-macos-gnu
|
||||
@@ -55,9 +55,9 @@ builds:
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version={{.Version}}
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit={{.Commit}}
|
||||
- -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildTime={{.Date}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.Version={{.Version}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.GitCommit={{.Commit}}
|
||||
- -X github.com/Wikid82/charon/backend/internal/version.BuildTime={{.Date}}
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
@@ -91,21 +91,21 @@ nfpms:
|
||||
- id: packages
|
||||
builds:
|
||||
- linux
|
||||
package_name: cpmp
|
||||
vendor: CaddyProxyManagerPlus
|
||||
homepage: https://github.com/Wikid82/CaddyProxyManagerPlus
|
||||
package_name: charon
|
||||
vendor: Charon
|
||||
homepage: https://github.com/Wikid82/charon
|
||||
maintainer: Wikid82
|
||||
description: "Caddy Proxy Manager Plus - A powerful reverse proxy manager"
|
||||
description: "Charon - A powerful reverse proxy manager"
|
||||
license: MIT
|
||||
formats:
|
||||
- deb
|
||||
- rpm
|
||||
contents:
|
||||
- src: ./backend/data/
|
||||
dst: /var/lib/cpmp/data/
|
||||
dst: /var/lib/charon/data/
|
||||
type: dir
|
||||
- src: ./frontend/dist/
|
||||
dst: /usr/share/cpmp/frontend/
|
||||
dst: /usr/share/charon/frontend/
|
||||
type: dir
|
||||
dependencies:
|
||||
- libc6
|
||||
|
||||
@@ -39,6 +39,12 @@ repos:
|
||||
language: system
|
||||
files: '\.go$'
|
||||
pass_filenames: false
|
||||
- id: check-version-match
|
||||
name: Check .version matches latest Git tag
|
||||
entry: bash -c 'scripts/check-version-match-tag.sh'
|
||||
language: system
|
||||
files: '\.version$'
|
||||
pass_filenames: false
|
||||
|
||||
# === MANUAL/CI-ONLY HOOKS ===
|
||||
# These are slow and should only run on-demand or in CI
|
||||
|
||||
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": "",
|
||||
"envManager": "ms-python.python:venv",
|
||||
"packageManager": "ms-python.python:pip"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
.vscode/tasks.json
vendored
6
.vscode/tasks.json
vendored
@@ -63,13 +63,13 @@
|
||||
{
|
||||
"label": "Build & Run Local Docker",
|
||||
"type": "shell",
|
||||
"command": "docker build --build-arg VCS_REF=$(git rev-parse HEAD) -t cpmp:local . && docker compose -f docker-compose.local.yml up -d",
|
||||
"command": "docker build --build-arg VCS_REF=$(git rev-parse HEAD) -t charon:local . && docker compose -f docker-compose.local.yml up -d",
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "Run Local Docker (debug)",
|
||||
"type": "shell",
|
||||
"command": "docker run --rm -it --name cpmp-debug --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -p 8080:8080 -p 2345:2345 -e CPM_ENV=development -e CPMP_DEBUG=1 cpmp:local",
|
||||
"command": "docker run --rm -it --name charon-debug --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -p 8080:8080 -p 2345:2345 -e CHARON_ENV=development -e CHARON_DEBUG=1 charon:local",
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
@@ -91,7 +91,7 @@
|
||||
"CRITICAL,HIGH",
|
||||
"--output",
|
||||
"/logs/trivy-report.txt",
|
||||
"cpmp:local"
|
||||
"charon:local"
|
||||
],
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Contributing to CaddyProxyManager+
|
||||
# Contributing to Charon
|
||||
|
||||
Thank you for your interest in contributing to CaddyProxyManager+! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
@@ -36,13 +36,13 @@ This project follows a Code of Conduct that all contributors are expected to adh
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/CaddyProxyManagerPlus.git
|
||||
cd CaddyProxyManagerPlus
|
||||
git clone https://github.com/YOUR_USERNAME/charon.git
|
||||
cd charon
|
||||
```
|
||||
|
||||
3. Add the upstream remote:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Wikid82/CaddyProxyManagerPlus.git
|
||||
git remote add upstream https://github.com/Wikid82/charon.git
|
||||
```
|
||||
|
||||
### Set Up Development Environment
|
||||
@@ -374,7 +374,7 @@ Contributors will be recognized in:
|
||||
|
||||
## Questions?
|
||||
|
||||
- Open a [Discussion](https://github.com/Wikid82/CaddyProxyManagerPlus/discussions) for general questions
|
||||
- Open a [Discussion](https://github.com/Wikid82/charon/discussions) for general questions
|
||||
- Join our community chat (coming soon)
|
||||
- Tag maintainers in issues for urgent matters
|
||||
|
||||
|
||||
52
DOCKER.md
52
DOCKER.md
@@ -1,13 +1,13 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
CaddyProxyManager+ is designed for Docker-first deployment, making it easy for home users to run Caddy without learning Caddyfile syntax.
|
||||
Charon is designed for Docker-first deployment, making it easy for home users to run Caddy without learning Caddyfile syntax.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Wikid82/CaddyProxyManagerPlus.git
|
||||
cd CaddyProxyManagerPlus
|
||||
git clone https://github.com/Wikid82/charon.git
|
||||
cd charon
|
||||
|
||||
# Start the stack
|
||||
docker-compose up -d
|
||||
@@ -18,16 +18,16 @@ open http://localhost:8080
|
||||
|
||||
## Architecture
|
||||
|
||||
CaddyProxyManager+ runs as a **single container** that includes:
|
||||
Charon runs as a **single container** that includes:
|
||||
1. **Caddy Server**: The reverse proxy engine (ports 80/443).
|
||||
2. **CPM+ Backend**: The Go API that manages Caddy via its API.
|
||||
3. **CPM+ Frontend**: The React web interface (port 8080).
|
||||
2. **Charon Backend**: The Go API that manages Caddy via its API (binary: `charon`, `cpmp` symlink preserved).
|
||||
3. **Charon Frontend**: The React web interface (port 8080).
|
||||
|
||||
This unified architecture simplifies deployment, updates, and data management.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Container (cpmp) │
|
||||
│ Container (charon / cpmp) │
|
||||
│ │
|
||||
│ ┌──────────┐ API ┌──────────────┐ │
|
||||
│ │ Caddy │◄──:2019──┤ CPM+ App │ │
|
||||
@@ -48,7 +48,7 @@ Persist your data by mounting these volumes:
|
||||
|
||||
| Host Path | Container Path | Description |
|
||||
|-----------|----------------|-------------|
|
||||
| `./data` | `/app/data` | **Critical**. Stores the SQLite database (`cpm.db`) and application logs. |
|
||||
| `./data` | `/app/data` | **Critical**. Stores the SQLite database (default `charon.db`, `cpm.db` fallback) and application logs. |
|
||||
| `./caddy_data` | `/data` | **Critical**. Stores Caddy's SSL certificates and keys. |
|
||||
| `./caddy_config` | `/config` | Stores Caddy's autosave configuration. |
|
||||
|
||||
@@ -58,33 +58,33 @@ Configure the application via `docker-compose.yml`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CPM_ENV` | `production` | Set to `development` for verbose logging. |
|
||||
| `CPM_HTTP_PORT` | `8080` | Port for the Web UI. |
|
||||
| `CPM_DB_PATH` | `/app/data/cpm.db` | Path to the SQLite database. |
|
||||
| `CPM_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API. |
|
||||
| `CHARON_ENV` | `production` | Set to `development` for verbose logging (`CPM_ENV` supported for backward compatibility). |
|
||||
| `CHARON_HTTP_PORT` | `8080` | Port for the Web UI (`CPM_HTTP_PORT` supported for backward compatibility). |
|
||||
| `CHARON_DB_PATH` | `/app/data/charon.db` | Path to the SQLite database (`CPM_DB_PATH` supported for backward compatibility). |
|
||||
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). |
|
||||
|
||||
## NAS Deployment Guides
|
||||
|
||||
### Synology (Container Manager / Docker)
|
||||
|
||||
1. **Prepare Folders**: Create a folder `docker/cpmp` and subfolders `data`, `caddy_data`, and `caddy_config`.
|
||||
2. **Download Image**: Search for `ghcr.io/wikid82/cpmp` in the Registry and download the `latest` tag.
|
||||
1. **Prepare Folders**: Create a folder `docker/charon` (or `docker/cpmp` for backward compatibility) and subfolders `data`, `caddy_data`, and `caddy_config`.
|
||||
2. **Download Image**: Search for `ghcr.io/wikid82/charon` in the Registry and download the `latest` tag.
|
||||
3. **Launch Container**:
|
||||
* **Network**: Use `Host` mode (recommended for Caddy to see real client IPs) OR bridge mode mapping ports `80:80`, `443:443`, and `8080:8080`.
|
||||
* **Volume Settings**:
|
||||
* `/docker/cpmp/data` -> `/app/data`
|
||||
* `/docker/cpmp/caddy_data` -> `/data`
|
||||
* `/docker/cpmp/caddy_config` -> `/config`
|
||||
* **Environment**: Add `CPM_ENV=production`.
|
||||
* `/docker/charon/data` -> `/app/data` (or `/docker/cpmp/data` -> `/app/data` for backward compatibility)
|
||||
* `/docker/charon/caddy_data` -> `/data` (or `/docker/cpmp/caddy_data` -> `/data` for backward compatibility)
|
||||
* `/docker/charon/caddy_config` -> `/config` (or `/docker/cpmp/caddy_config` -> `/config` for backward compatibility)
|
||||
* **Environment**: Add `CHARON_ENV=production` (or `CPM_ENV=production` for backward compatibility).
|
||||
4. **Finish**: Start the container and access `http://YOUR_NAS_IP:8080`.
|
||||
|
||||
### Unraid
|
||||
|
||||
1. **Community Apps**: (Coming Soon) Search for "CaddyProxyManagerPlus".
|
||||
1. **Community Apps**: (Coming Soon) Search for "charon".
|
||||
2. **Manual Install**:
|
||||
* Click **Add Container**.
|
||||
* **Name**: CaddyProxyManagerPlus
|
||||
* **Repository**: `ghcr.io/wikid82/cpmp:latest`
|
||||
* **Name**: Charon
|
||||
* **Repository**: `ghcr.io/wikid82/charon:latest`
|
||||
* **Network Type**: Bridge
|
||||
* **WebUI**: `http://[IP]:[PORT:8080]`
|
||||
* **Port mappings**:
|
||||
@@ -92,9 +92,9 @@ Configure the application via `docker-compose.yml`:
|
||||
* Container Port: `443` -> Host Port: `443`
|
||||
* Container Port: `8080` -> Host Port: `8080`
|
||||
* **Paths**:
|
||||
* `/mnt/user/appdata/cpmp/data` -> `/app/data`
|
||||
* `/mnt/user/appdata/cpmp/caddy_data` -> `/data`
|
||||
* `/mnt/user/appdata/cpmp/caddy_config` -> `/config`
|
||||
* `/mnt/user/appdata/charon/data` -> `/app/data` (or `/mnt/user/appdata/cpmp/data` -> `/app/data` for backward compatibility)
|
||||
* `/mnt/user/appdata/charon/caddy_data` -> `/data` (or `/mnt/user/appdata/cpmp/caddy_data` -> `/data` for backward compatibility)
|
||||
* `/mnt/user/appdata/charon/caddy_config` -> `/config` (or `/mnt/user/appdata/cpmp/caddy_config` -> `/config` for backward compatibility)
|
||||
3. **Apply**: Click Done to pull and start.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -146,7 +146,7 @@ For specific versions:
|
||||
|
||||
```bash
|
||||
# Edit docker-compose.yml to pin version
|
||||
image: ghcr.io/wikid82/caddyproxymanagerplus:v1.0.0
|
||||
image: ghcr.io/wikid82/charon:v1.0.0
|
||||
|
||||
docker-compose up -d
|
||||
```
|
||||
@@ -155,7 +155,7 @@ docker-compose up -d
|
||||
|
||||
```bash
|
||||
# Build multi-arch images
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t caddyproxymanager-plus:local .
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t charon:local .
|
||||
|
||||
# Or use Make
|
||||
make docker-build
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,4 +1,4 @@
|
||||
# Multi-stage Dockerfile for CaddyProxyManager+ with integrated Caddy
|
||||
# Multi-stage Dockerfile for Charon with integrated Caddy
|
||||
# Single container deployment for simplified home user setup
|
||||
|
||||
# Build arguments for versioning
|
||||
@@ -76,10 +76,10 @@ ARG BUILD_DATE=unknown
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
CGO_ENABLED=1 xx-go build \
|
||||
-ldflags "-s -w -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version=${VERSION} \
|
||||
-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit=${VCS_REF} \
|
||||
-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildTime=${BUILD_DATE}" \
|
||||
-o cpmp ./cmd/api
|
||||
-ldflags "-s -w -X github.com/Wikid82/charon/backend/internal/version.Version=${VERSION} \
|
||||
-X github.com/Wikid82/charon/backend/internal/version.GitCommit=${VCS_REF} \
|
||||
-X github.com/Wikid82/charon/backend/internal/version.BuildTime=${BUILD_DATE}" \
|
||||
-o charon ./cmd/api
|
||||
|
||||
# ---- Caddy Builder ----
|
||||
# Build Caddy from source to ensure we use the latest Go version and dependencies
|
||||
@@ -125,7 +125,8 @@ RUN mkdir -p /app/data/geoip && \
|
||||
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
# Copy Go binary from backend builder
|
||||
COPY --from=backend-builder /app/backend/cpmp /app/cpmp
|
||||
COPY --from=backend-builder /app/backend/charon /app/charon
|
||||
RUN ln -s /app/charon /app/cpmp || true
|
||||
# Copy Delve debugger (xx-go install places it in /go/bin)
|
||||
COPY --from=backend-builder /go/bin/dlv /usr/local/bin/dlv
|
||||
|
||||
@@ -137,7 +138,14 @@ COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Set default environment variables
|
||||
ENV CPM_ENV=production \
|
||||
ENV CHARON_ENV=production \
|
||||
CHARON_HTTP_PORT=8080 \
|
||||
CHARON_DB_PATH=/app/data/charon.db \
|
||||
CHARON_FRONTEND_DIR=/app/frontend/dist \
|
||||
CHARON_CADDY_ADMIN_API=http://localhost:2019 \
|
||||
CHARON_CADDY_CONFIG_DIR=/app/data/caddy \
|
||||
CHARON_GEOIP_DB_PATH=/app/data/geoip/GeoLite2-Country.mmdb \
|
||||
CPM_ENV=production \
|
||||
CPM_HTTP_PORT=8080 \
|
||||
CPM_DB_PATH=/app/data/cpm.db \
|
||||
CPM_FRONTEND_DIR=/app/frontend/dist \
|
||||
@@ -154,14 +162,14 @@ ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
# OCI image labels for version metadata
|
||||
LABEL org.opencontainers.image.title="CaddyProxyManager+ (CPMP)" \
|
||||
LABEL org.opencontainers.image.title="Charon (CPMP)" \
|
||||
org.opencontainers.image.description="Web UI for managing Caddy reverse proxy configurations" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}" \
|
||||
org.opencontainers.image.source="https://github.com/Wikid82/CaddyProxyManagerPlus" \
|
||||
org.opencontainers.image.url="https://github.com/Wikid82/CaddyProxyManagerPlus" \
|
||||
org.opencontainers.image.vendor="CaddyProxyManagerPlus" \
|
||||
org.opencontainers.image.source="https://github.com/Wikid82/charon" \
|
||||
org.opencontainers.image.url="https://github.com/Wikid82/charon" \
|
||||
org.opencontainers.image.vendor="charon" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Expose ports
|
||||
|
||||
32
Makefile
32
Makefile
@@ -2,7 +2,7 @@
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "CaddyProxyManager+ Build System"
|
||||
@echo "Charon Build System"
|
||||
@echo ""
|
||||
@echo "Available targets:"
|
||||
@echo " install - Install all dependencies (backend + frontend)"
|
||||
@@ -43,6 +43,16 @@ build:
|
||||
@echo "Building backend..."
|
||||
cd backend && go build -o bin/api ./cmd/api
|
||||
|
||||
build-versioned:
|
||||
@echo "Building frontend (versioned)..."
|
||||
cd frontend && VITE_APP_VERSION=$$(git describe --tags --always --dirty) npm run build
|
||||
@echo "Building backend (versioned)..."
|
||||
cd backend && \
|
||||
VERSION=$$(git describe --tags --always --dirty); \
|
||||
GIT_COMMIT=$$(git rev-parse --short HEAD); \
|
||||
BUILD_DATE=$$(date -u +'%Y-%m-%dT%H:%M:%SZ'); \
|
||||
go build -ldflags "-X github.com/Wikid82/charon/backend/internal/version.Version=$$VERSION -X github.com/Wikid82/charon/backend/internal/version.GitCommit=$$GIT_COMMIT -X github.com/Wikid82/charon/backend/internal/version.BuildTime=$$BUILD_DATE" -o bin/api ./cmd/api
|
||||
|
||||
# Run backend in development mode
|
||||
run:
|
||||
cd backend && go run ./cmd/api
|
||||
@@ -64,15 +74,15 @@ docker-build:
|
||||
|
||||
# Build Docker image with version
|
||||
docker-build-versioned:
|
||||
@VERSION=$$(cat .version 2>/dev/null || echo "dev"); \
|
||||
@VERSION=$$(cat .version 2>/dev/null || git describe --tags --always --dirty 2>/dev/null || echo "dev"); \
|
||||
BUILD_DATE=$$(date -u +'%Y-%m-%dT%H:%M:%SZ'); \
|
||||
VCS_REF=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker build \
|
||||
--build-arg VERSION=$$VERSION \
|
||||
--build-arg BUILD_DATE=$$BUILD_DATE \
|
||||
--build-arg VCS_REF=$$VCS_REF \
|
||||
-t cpmp:$$VERSION \
|
||||
-t cpmp:latest \
|
||||
-t charon:$$VERSION \
|
||||
-t charon:latest \
|
||||
.
|
||||
|
||||
# Run Docker containers (production)
|
||||
@@ -94,9 +104,9 @@ docker-logs:
|
||||
# Development mode (requires tmux)
|
||||
dev:
|
||||
@command -v tmux >/dev/null 2>&1 || { echo "tmux is required for dev mode"; exit 1; }
|
||||
tmux new-session -d -s cpm 'cd backend && go run ./cmd/api'
|
||||
tmux split-window -h -t cpm 'cd frontend && npm run dev'
|
||||
tmux attach -t cpm
|
||||
tmux new-session -d -s charon 'cd backend && go run ./cmd/api'
|
||||
tmux split-window -h -t charon 'cd frontend && npm run dev'
|
||||
tmux attach -t charon
|
||||
|
||||
# Create a new release (interactive script)
|
||||
release:
|
||||
@@ -109,14 +119,14 @@ security-scan:
|
||||
|
||||
security-scan-full:
|
||||
@echo "Building local Docker image for security scan..."
|
||||
docker build --build-arg VCS_REF=$(shell git rev-parse HEAD) -t cpmp:local .
|
||||
docker build --build-arg VCS_REF=$(shell git rev-parse HEAD) -t charon:local .
|
||||
@echo "Running Trivy container scan..."
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v $(HOME)/.cache/trivy:/root/.cache/trivy \
|
||||
aquasec/trivy:latest image \
|
||||
--severity CRITICAL,HIGH \
|
||||
cpmp:local
|
||||
charon:local
|
||||
|
||||
security-scan-deps:
|
||||
@echo "Scanning Go dependencies..."
|
||||
@@ -137,6 +147,10 @@ test-race:
|
||||
@echo "Running Go tests with race detection..."
|
||||
cd backend && go test -race -v ./...
|
||||
|
||||
check-module-coverage:
|
||||
@echo "Running module-specific coverage checks (backend + frontend)"
|
||||
@bash scripts/check-module-coverage.sh
|
||||
|
||||
benchmark:
|
||||
@echo "Running Go benchmarks..."
|
||||
cd backend && go test -bench=. -benchmem ./...
|
||||
|
||||
24
VERSION.md
24
VERSION.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## Semantic Versioning
|
||||
|
||||
CaddyProxyManager+ follows [Semantic Versioning 2.0.0](https://semver.org/):
|
||||
Charon follows [Semantic Versioning 2.0.0](https://semver.org/):
|
||||
|
||||
- **MAJOR.MINOR.PATCH** (e.g., `1.2.3`)
|
||||
- **MAJOR**: Incompatible API changes
|
||||
@@ -62,16 +62,16 @@ Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2`
|
||||
|
||||
```bash
|
||||
# Use latest stable release
|
||||
docker pull ghcr.io/wikid82/cpmp:latest
|
||||
docker pull ghcr.io/wikid82/charon:latest
|
||||
|
||||
# Use specific version
|
||||
docker pull ghcr.io/wikid82/cpmp:v1.0.0
|
||||
docker pull ghcr.io/wikid82/charon:v1.0.0
|
||||
|
||||
# Use development builds
|
||||
docker pull ghcr.io/wikid82/cpmp:development
|
||||
docker pull ghcr.io/wikid82/charon:development
|
||||
|
||||
# Use specific commit
|
||||
docker pull ghcr.io/wikid82/cpmp:main-abc123
|
||||
docker pull ghcr.io/wikid82/charon:main-abc123
|
||||
```
|
||||
|
||||
## Version Information
|
||||
@@ -86,7 +86,7 @@ Response includes:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "caddy-proxy-manager-plus",
|
||||
"service": "charon",
|
||||
"version": "1.0.0",
|
||||
"git_commit": "abc1234567890def",
|
||||
"build_date": "2025-11-17T12:34:56Z"
|
||||
@@ -97,7 +97,7 @@ Response includes:
|
||||
|
||||
View version metadata:
|
||||
```bash
|
||||
docker inspect ghcr.io/wikid82/cpmp:latest \
|
||||
docker inspect ghcr.io/wikid82/charon:latest \
|
||||
--format='{{json .Config.Labels}}' | jq
|
||||
```
|
||||
|
||||
@@ -111,7 +111,7 @@ Returns OCI-compliant labels:
|
||||
|
||||
Local builds default to `version=dev`:
|
||||
```bash
|
||||
docker build -t cpmp:dev .
|
||||
docker build -t charon:dev .
|
||||
```
|
||||
|
||||
Build with custom version:
|
||||
@@ -120,7 +120,7 @@ docker build \
|
||||
--build-arg VERSION=1.2.3 \
|
||||
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
|
||||
--build-arg VCS_REF=$(git rev-parse HEAD) \
|
||||
-t caddyproxymanagerplus:1.2.3 .
|
||||
-t charon:1.2.3 .
|
||||
```
|
||||
|
||||
## Changelog Generation
|
||||
@@ -140,3 +140,9 @@ Example:
|
||||
git commit -m "feat: add TLS certificate management"
|
||||
git commit -m "fix: correct proxy timeout handling"
|
||||
```
|
||||
|
||||
## CI Tag-based Releases (recommended)
|
||||
|
||||
- CI derives the release `Version` from the Git tag (e.g., `v1.2.3`) and embeds this value into the backend binary via Go ldflags; frontend reads the version from the backend's API. This avoids automatic commits to `main`.
|
||||
- The `.version` file is optional. If present, use the `scripts/check-version-match-tag.sh` script or the included pre-commit hook to validate that `.version` matches the latest Git tag.
|
||||
- CI will still generate changelogs automatically using the release-drafter workflow and create GitHub Releases when tags are pushed.
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
CHARON_ENV=development
|
||||
CHARON_HTTP_PORT=8080
|
||||
CHARON_DB_PATH=./data/charon.db
|
||||
CHARON_CADDY_ADMIN_API=http://localhost:2019
|
||||
CHARON_CADDY_CONFIG_DIR=./data/caddy
|
||||
CERBERUS_SECURITY_CERBERUS_ENABLED=false
|
||||
CHARON_SECURITY_CERBERUS_ENABLED=false
|
||||
CPM_SECURITY_CERBERUS_ENABLED=false
|
||||
|
||||
# Backward compatibility (CPM_ prefixes are still supported)
|
||||
CPM_ENV=development
|
||||
CPM_HTTP_PORT=8080
|
||||
CPM_DB_PATH=./data/cpm.db
|
||||
|
||||
BIN
backend/bin/api
Executable file
BIN
backend/bin/api
Executable file
Binary file not shown.
1658
backend/caddy.html
Normal file
1658
backend/caddy.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/routes"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/database"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/server"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/database"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/server"
|
||||
"github.com/Wikid82/charon/backend/internal/version"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
@@ -27,7 +27,7 @@ func main() {
|
||||
_ = os.MkdirAll(logDir, 0755)
|
||||
}
|
||||
|
||||
logFile := filepath.Join(logDir, "cpmp.log")
|
||||
logFile := filepath.Join(logDir, "charon.log")
|
||||
rotator := &lumberjack.Logger{
|
||||
Filename: logFile,
|
||||
MaxSize: 10, // megabytes
|
||||
@@ -36,6 +36,12 @@ func main() {
|
||||
Compress: true,
|
||||
}
|
||||
|
||||
// Ensure legacy cpmp.log exists as symlink for compatibility
|
||||
legacyLog := filepath.Join(logDir, "cpmp.log")
|
||||
if _, err := os.Lstat(legacyLog); os.IsNotExist(err) {
|
||||
_ = os.Symlink(logFile, legacyLog) // ignore errors
|
||||
}
|
||||
|
||||
// Log to both stdout and file
|
||||
mw := io.MultiWriter(os.Stdout, rotator)
|
||||
log.SetOutput(mw)
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Connect to database
|
||||
db, err := gorm.Open(sqlite.Open("./data/cpm.db"), &gorm.Config{})
|
||||
db, err := gorm.Open(sqlite.Open("./data/charon.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func main() {
|
||||
settings := []models.Setting{
|
||||
{
|
||||
Key: "app_name",
|
||||
Value: "Caddy Proxy Manager+",
|
||||
Value: "Charon",
|
||||
Type: "string",
|
||||
Category: "general",
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/Wikid82/CaddyProxyManagerPlus/backend
|
||||
module github.com/Wikid82/charon/backend
|
||||
|
||||
go 1.25.4
|
||||
|
||||
|
||||
1648
backend/importer.html
Normal file
1648
backend/importer.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
|
||||
@@ -3,7 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string) {
|
||||
@@ -22,19 +22,19 @@ func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string
|
||||
tmpDir, err := os.MkdirTemp("", "cpm-backup-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Structure: tmpDir/data/cpm.db
|
||||
// BackupService expects DatabasePath to be .../data/cpm.db
|
||||
// Structure: tmpDir/data/charon.db
|
||||
// BackupService expects DatabasePath to be .../data/charon.db
|
||||
// It sets DataDir to filepath.Dir(DatabasePath) -> .../data
|
||||
// It sets BackupDir to .../data/backups (Wait, let me check the code again)
|
||||
|
||||
// Code: backupDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "backups")
|
||||
// So if DatabasePath is /tmp/data/cpm.db, DataDir is /tmp/data, BackupDir is /tmp/data/backups.
|
||||
// So if DatabasePath is /tmp/data/charon.db, DataDir is /tmp/data, BackupDir is /tmp/data/backups.
|
||||
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
err = os.MkdirAll(dataDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbPath := filepath.Join(dataDir, "cpm.db")
|
||||
dbPath := filepath.Join(dataDir, "charon.db")
|
||||
// Create a dummy DB file to back up
|
||||
err = os.WriteFile(dbPath, []byte("dummy db content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
type CertificateHandler struct {
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupTestDB() *gorm.DB {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version"
|
||||
"github.com/Wikid82/charon/backend/internal/version"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/health", HealthHandler)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/health", HealthHandler)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
req, _ := http.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp["status"])
|
||||
assert.NotEmpty(t, resp["version"])
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp["status"])
|
||||
assert.NotEmpty(t, resp["version"])
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/caddy"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
// ImportHandler handles Caddyfile import operations.
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupImportTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ func (h *LogsHandler) Download(c *gin.Context) {
|
||||
|
||||
// Create a temporary file to serve a consistent snapshot
|
||||
// This prevents Content-Length mismatches if the live log file grows during download
|
||||
tmpFile, err := os.CreateTemp("", "cpmp-log-*.log")
|
||||
tmpFile, err := os.CreateTemp("", "charon-log-*.log")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
|
||||
return
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) {
|
||||
@@ -29,7 +29,7 @@ func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) {
|
||||
err = os.MkdirAll(dataDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbPath := filepath.Join(dataDir, "cpm.db")
|
||||
dbPath := filepath.Join(dataDir, "charon.db")
|
||||
|
||||
// Create logs dir
|
||||
logsDir := filepath.Join(dataDir, "logs")
|
||||
@@ -42,7 +42,11 @@ func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) {
|
||||
|
||||
err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(log1+"\n"+log2+"\n"), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(logsDir, "cpmp.log"), []byte("app log line 1\napp log line 2"), 0644)
|
||||
// Write a charon.log and create a cpmp.log symlink to it for compatibility
|
||||
err = os.WriteFile(filepath.Join(logsDir, "charon.log"), []byte("app log line 1\napp log line 2"), 0644)
|
||||
require.NoError(t, err)
|
||||
// Create legacy cpmp log symlink
|
||||
_ = os.Symlink(filepath.Join(logsDir, "charon.log"), filepath.Join(logsDir, "cpmp.log"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
@@ -145,7 +149,7 @@ func TestLogsHandler_PathTraversal(t *testing.T) {
|
||||
c.Params = gin.Params{{Key: "filename", Value: "../access.log"}}
|
||||
|
||||
cfg := &config.Config{
|
||||
DatabasePath: filepath.Join(tmpDir, "data", "cpm.db"),
|
||||
DatabasePath: filepath.Join(tmpDir, "data", "charon.db"),
|
||||
}
|
||||
svc := services.NewLogService(cfg)
|
||||
h := NewLogsHandler(svc)
|
||||
|
||||
@@ -3,7 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupNotificationTestDB() *gorm.DB {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/caddy"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
// ProxyHostHandler handles CRUD operations for proxy hosts.
|
||||
@@ -123,10 +123,35 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(host); err != nil {
|
||||
var incoming models.ProxyHost
|
||||
if err := c.ShouldBindJSON(&incoming); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Backup advanced config if changed
|
||||
if incoming.AdvancedConfig != host.AdvancedConfig {
|
||||
incoming.AdvancedConfigBackup = host.AdvancedConfig
|
||||
}
|
||||
|
||||
// Copy incoming fields into host
|
||||
host.Name = incoming.Name
|
||||
host.DomainNames = incoming.DomainNames
|
||||
host.ForwardScheme = incoming.ForwardScheme
|
||||
host.ForwardHost = incoming.ForwardHost
|
||||
host.ForwardPort = incoming.ForwardPort
|
||||
host.SSLForced = incoming.SSLForced
|
||||
host.HTTP2Support = incoming.HTTP2Support
|
||||
host.HSTSEnabled = incoming.HSTSEnabled
|
||||
host.HSTSSubdomains = incoming.HSTSSubdomains
|
||||
host.BlockExploits = incoming.BlockExploits
|
||||
host.WebsocketSupport = incoming.WebsocketSupport
|
||||
host.Application = incoming.Application
|
||||
host.Enabled = incoming.Enabled
|
||||
host.CertificateID = incoming.CertificateID
|
||||
host.AccessListID = incoming.AccessListID
|
||||
host.Locations = incoming.Locations
|
||||
host.AdvancedConfig = incoming.AdvancedConfig
|
||||
host.AdvancedConfigBackup = incoming.AdvancedConfigBackup
|
||||
|
||||
if err := h.service.Update(host); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/caddy"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
// RemoteServerHandler handles HTTP requests for remote server management.
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServerHandler) {
|
||||
|
||||
@@ -2,27 +2,48 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
// SecurityHandler handles security-related API requests.
|
||||
type SecurityHandler struct {
|
||||
cfg config.SecurityConfig
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSecurityHandler creates a new SecurityHandler.
|
||||
func NewSecurityHandler(cfg config.SecurityConfig) *SecurityHandler {
|
||||
func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB) *SecurityHandler {
|
||||
return &SecurityHandler{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the current status of all security services.
|
||||
func (h *SecurityHandler) GetStatus(c *gin.Context) {
|
||||
enabled := h.cfg.CerberusEnabled
|
||||
// Check runtime setting override
|
||||
var settingKey = "security.cerberus.enabled"
|
||||
if h.db != nil {
|
||||
var setting struct {
|
||||
Value string
|
||||
}
|
||||
if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", settingKey).Scan(&setting).Error; err == nil {
|
||||
if strings.EqualFold(setting.Value, "true") {
|
||||
enabled = true
|
||||
} else {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cerberus": gin.H{"enabled": enabled},
|
||||
"crowdsec": gin.H{
|
||||
"mode": h.cfg.CrowdSecMode,
|
||||
"api_url": h.cfg.CrowdSecAPIURL,
|
||||
|
||||
82
backend/internal/api/handlers/security_handler_clean_test.go
Normal file
82
backend/internal/api/handlers/security_handler_clean_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
// lightweight in-memory DB unique per test run
|
||||
dsn := fmt.Sprintf("file:security_handler_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open DB: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Setting{}); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSecurityHandler_GetStatus_Clean(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Basic disabled scenario
|
||||
cfg := config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
}
|
||||
handler := NewSecurityHandler(cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response["cerberus"])
|
||||
}
|
||||
|
||||
func TestSecurityHandler_Cerberus_DBOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db := setupTestDB(t)
|
||||
// set DB to enable cerberus
|
||||
if err := db.Create(&models.Setting{Key: "security.cerberus.enabled", Value: "true"}).Error; err != nil {
|
||||
t.Fatalf("failed to insert setting: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.SecurityConfig{CerberusEnabled: false}
|
||||
handler := NewSecurityHandler(cfg, db)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
cerb := response["cerberus"].(map[string]interface{})
|
||||
assert.Equal(t, true, cerb["enabled"].(bool))
|
||||
}
|
||||
@@ -1,3 +1,781 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
// The original file had duplicated content and misplaced build tags.
|
||||
// Keep a single, well-structured test to verify both enabled/disabled security states.
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
json.Unmarshal(expectedJSON, &expectedNormalized)
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package handlers
|
||||
|
||||
/*
|
||||
File intentionally ignored/build-tagged - see security_handler_clean_test.go for tests.
|
||||
*/
|
||||
|
||||
// EOF
|
||||
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
json.Unmarshal(expectedJSON, &expectedNormalized)
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Helper to convert map[string]interface{} to JSON and back to normalize types
|
||||
// (e.g. int vs float64)
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
json.Unmarshal(expectedJSON, &expectedNormalized)
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Helper to convert map[string]interface{} to JSON and back to normalize types
|
||||
// (e.g. int vs float64)
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
json.Unmarshal(expectedJSON, &expectedNormalized)
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Helper to convert map[string]interface{} to JSON and back to normalize types
|
||||
// (e.g. int vs float64)
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
json.Unmarshal(expectedJSON, &expectedNormalized)
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Helper to convert map[string]interface{} to JSON and back to normalize types
|
||||
// (e.g. int vs float64)
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
json.Unmarshal(expectedJSON, &expectedNormalized)
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Helper to convert map[string]interface{} to JSON and back to normalize types
|
||||
// (e.g. int vs float64)
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
json.Unmarshal(expectedJSON, &expectedNormalized)
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -9,7 +787,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
@@ -22,7 +800,8 @@ func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
@@ -74,7 +853,7 @@ func TestSecurityHandler_GetStatus(t *testing.T) {
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
|
||||
111
backend/internal/api/handlers/security_handler_test_fixed.go
Normal file
111
backend/internal/api/handlers/security_handler_test_fixed.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_GetStatus_Fixed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SecurityConfig
|
||||
expectedStatus int
|
||||
expectedBody map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "All Disabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "disabled",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": false},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"api_url": "",
|
||||
"enabled": false,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "disabled",
|
||||
"enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "All Enabled",
|
||||
cfg: config.SecurityConfig{
|
||||
CrowdSecMode: "local",
|
||||
WAFMode: "enabled",
|
||||
RateLimitMode: "enabled",
|
||||
ACLMode: "enabled",
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedBody: map[string]interface{}{
|
||||
"cerberus": map[string]interface{}{"enabled": true},
|
||||
"crowdsec": map[string]interface{}{
|
||||
"mode": "local",
|
||||
"api_url": "",
|
||||
"enabled": true,
|
||||
},
|
||||
"waf": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"rate_limit": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
"acl": map[string]interface{}{
|
||||
"mode": "enabled",
|
||||
"enabled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
handler := NewSecurityHandler(tt.cfg, nil)
|
||||
router := gin.New()
|
||||
router.GET("/security/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/security/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectedJSON, _ := json.Marshal(tt.expectedBody)
|
||||
var expectedNormalized map[string]interface{}
|
||||
if err := json.Unmarshal(expectedJSON, &expectedNormalized); err != nil {
|
||||
t.Fatalf("failed to unmarshal expected JSON: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedNormalized, response)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupSettingsTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
@@ -3,7 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func TestUpdateHandler_Check(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/middleware"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/charon/backend/internal/api/middleware"
|
||||
"github.com/Wikid82/charon/backend/internal/caddy"
|
||||
"github.com/Wikid82/charon/backend/internal/cerberus"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
// Register wires up API routes and performs automatic migrations.
|
||||
@@ -59,6 +60,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
|
||||
// Cerberus middleware applies the optional security suite checks (WAF, ACL, CrowdSec)
|
||||
cerb := cerberus.New(cfg.Security, db)
|
||||
api.Use(cerb.Middleware())
|
||||
|
||||
// Auth routes
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
@@ -178,7 +183,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
})
|
||||
|
||||
// Security Status
|
||||
securityHandler := handlers.NewSecurityHandler(cfg.Security)
|
||||
securityHandler := handlers.NewSecurityHandler(cfg.Security, db)
|
||||
protected.GET("/security/status", securityHandler.GetStatus)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/routes"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupTestImportDB(t *testing.T) *gorm.DB {
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
// Use in-memory DB
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
// Use in-memory DB
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.Config{
|
||||
JWTSecret: "test-secret",
|
||||
}
|
||||
|
||||
err = Register(router, db, cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify some routes are registered
|
||||
routes := router.Routes()
|
||||
assert.NotEmpty(t, routes)
|
||||
|
||||
foundHealth := false
|
||||
for _, r := range routes {
|
||||
if r.Path == "/api/v1/health" {
|
||||
foundHealth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundHealth, "Health route should be registered")
|
||||
cfg := config.Config{
|
||||
JWTSecret: "test-secret",
|
||||
}
|
||||
|
||||
err = Register(router, db, cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify some routes are registered
|
||||
routes := router.Routes()
|
||||
assert.NotEmpty(t, routes)
|
||||
|
||||
foundHealth := false
|
||||
for _, r := range routes {
|
||||
if r.Path == "/api/v1/health" {
|
||||
foundHealth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundHealth, "Health route should be registered")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test hook for json marshalling to allow simulating failures in tests
|
||||
var jsonMarshalClient = json.Marshal
|
||||
|
||||
// Client wraps the Caddy admin API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
@@ -29,7 +32,7 @@ func NewClient(adminAPIURL string) *Client {
|
||||
// Load atomically replaces Caddy's entire configuration.
|
||||
// This is the primary method for applying configuration changes.
|
||||
func (c *Client) Load(ctx context.Context, config *Config) error {
|
||||
body, err := json.Marshal(config)
|
||||
body, err := jsonMarshalClient(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package caddy
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestClient_Load_Success(t *testing.T) {
|
||||
@@ -94,6 +95,19 @@ func TestClient_Ping_Unreachable(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Load_CreateRequestFailure(t *testing.T) {
|
||||
// Use baseURL that makes NewRequest return error
|
||||
client := NewClient(":bad-url")
|
||||
err := client.Load(context.Background(), &Config{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Ping_CreateRequestFailure(t *testing.T) {
|
||||
client := NewClient(":bad-url")
|
||||
err := client.Ping(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClient_GetConfig_Failure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
@@ -161,3 +175,29 @@ func TestClient_NetworkErrors(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "execute request")
|
||||
}
|
||||
|
||||
func TestClient_Load_MarshalFailure(t *testing.T) {
|
||||
// Simulate json.Marshal failure
|
||||
orig := jsonMarshalClient
|
||||
jsonMarshalClient = func(v interface{}) ([]byte, error) { return nil, fmt.Errorf("marshal error") }
|
||||
defer func() { jsonMarshalClient = orig }()
|
||||
|
||||
client := NewClient("http://localhost")
|
||||
err := client.Load(context.Background(), &Config{})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "marshal config")
|
||||
}
|
||||
|
||||
type failingTransport struct{}
|
||||
|
||||
func (f *failingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("round trip failed")
|
||||
}
|
||||
|
||||
func TestClient_Ping_TransportError(t *testing.T) {
|
||||
client := NewClient("http://example.com")
|
||||
client.httpClient = &http.Client{Transport: &failingTransport{}}
|
||||
err := client.Ping(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "caddy unreachable")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
|
||||
@@ -243,6 +243,35 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
|
||||
|
||||
// Main proxy handler
|
||||
dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort)
|
||||
// Insert user advanced config (if present) as headers or handlers before the reverse proxy
|
||||
// so user-specified headers/handlers are applied prior to proxying.
|
||||
if host.AdvancedConfig != "" {
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil {
|
||||
fmt.Printf("Warning: Failed to parse advanced_config for host %s: %v\n", host.UUID, err)
|
||||
} else {
|
||||
switch v := parsed.(type) {
|
||||
case map[string]interface{}:
|
||||
// Append as a handler
|
||||
// Ensure it has a "handler" key
|
||||
if _, ok := v["handler"]; ok {
|
||||
handlers = append(handlers, Handler(v))
|
||||
} else {
|
||||
fmt.Printf("Warning: advanced_config for host %s is not a handler object\n", host.UUID)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, it := range v {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
if _, ok2 := m["handler"]; ok2 {
|
||||
handlers = append(handlers, Handler(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Warning: advanced_config for host %s has unexpected JSON structure\n", host.UUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application))
|
||||
|
||||
route := &Route{
|
||||
@@ -269,7 +298,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin
|
||||
routes = append(routes, catchAllRoute)
|
||||
}
|
||||
|
||||
config.Apps.HTTP.Servers["cpm_server"] = &Server{
|
||||
config.Apps.HTTP.Servers["charon_server"] = &Server{
|
||||
Listen: []string{":80", ":443"},
|
||||
Routes: routes,
|
||||
AutoHTTPS: &AutoHTTPSConfig{
|
||||
|
||||
25
backend/internal/caddy/config_buildacl_additional_test.go
Normal file
25
backend/internal/caddy/config_buildacl_additional_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildACLHandler_GeoBlacklist(t *testing.T) {
|
||||
acl := &models.AccessList{Type: "geo_blacklist", CountryCodes: "GB,FR", Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, h)
|
||||
b, _ := json.Marshal(h)
|
||||
require.Contains(t, string(b), "Access denied: Geographic restriction")
|
||||
}
|
||||
|
||||
func TestBuildACLHandler_UnknownTypeReturnsNil(t *testing.T) {
|
||||
acl := &models.AccessList{Type: "unknown_type", Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, h)
|
||||
}
|
||||
63
backend/internal/caddy/config_buildacl_test.go
Normal file
63
backend/internal/caddy/config_buildacl_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildACLHandler_GeoWhitelist(t *testing.T) {
|
||||
acl := &models.AccessList{Type: "geo_whitelist", CountryCodes: "US,CA", Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, h)
|
||||
|
||||
// Ensure it contains static_response status_code 403
|
||||
b, _ := json.Marshal(h)
|
||||
require.Contains(t, string(b), "Access denied: Geographic restriction")
|
||||
}
|
||||
|
||||
func TestBuildACLHandler_LocalNetwork(t *testing.T) {
|
||||
acl := &models.AccessList{Type: "whitelist", LocalNetworkOnly: true, Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, h)
|
||||
b, _ := json.Marshal(h)
|
||||
require.Contains(t, string(b), "Access denied: Not a local network IP")
|
||||
}
|
||||
|
||||
func TestBuildACLHandler_IPRules(t *testing.T) {
|
||||
rules := `[ {"cidr": "192.168.1.0/24", "description": "local"} ]`
|
||||
acl := &models.AccessList{Type: "blacklist", IPRules: rules, Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, h)
|
||||
b, _ := json.Marshal(h)
|
||||
require.Contains(t, string(b), "Access denied: IP blacklisted")
|
||||
}
|
||||
|
||||
func TestBuildACLHandler_InvalidIPJSON(t *testing.T) {
|
||||
acl := &models.AccessList{Type: "blacklist", IPRules: `invalid-json`, Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, h)
|
||||
}
|
||||
|
||||
func TestBuildACLHandler_NoIPRulesReturnsNil(t *testing.T) {
|
||||
acl := &models.AccessList{Type: "blacklist", IPRules: `[]`, Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, h)
|
||||
}
|
||||
|
||||
func TestBuildACLHandler_Whitelist(t *testing.T) {
|
||||
rules := `[ { "cidr": "192.168.1.0/24", "description": "local" } ]`
|
||||
acl := &models.AccessList{Type: "whitelist", IPRules: rules, Enabled: true}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, h)
|
||||
b, _ := json.Marshal(h)
|
||||
require.Contains(t, string(b), "Access denied: IP not in whitelist")
|
||||
}
|
||||
149
backend/internal/caddy/config_extra_test.go
Normal file
149
backend/internal/caddy/config_extra_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateConfig_CatchAllFrontend(t *testing.T) {
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false)
|
||||
require.NoError(t, err)
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
require.NotNil(t, server)
|
||||
require.Len(t, server.Routes, 1)
|
||||
r := server.Routes[0]
|
||||
// Expect first handler is rewrite to unknown.html
|
||||
require.Equal(t, "rewrite", r.Handle[0]["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_AdvancedInvalidJSON(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "adv1",
|
||||
DomainNames: "adv.example.com",
|
||||
ForwardHost: "app",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
AdvancedConfig: "{invalid-json",
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
require.NotNil(t, server)
|
||||
// Main route should still have ReverseProxy as last handler
|
||||
require.Len(t, server.Routes, 1)
|
||||
route := server.Routes[0]
|
||||
last := route.Handle[len(route.Handle)-1]
|
||||
require.Equal(t, "reverse_proxy", last["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_AdvancedArrayHandler(t *testing.T) {
|
||||
array := []map[string]interface{}{{
|
||||
"handler": "headers",
|
||||
"response": map[string]interface{}{
|
||||
"set": map[string][]string{"X-Test": {"1"}},
|
||||
},
|
||||
}}
|
||||
raw, _ := json.Marshal(array)
|
||||
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "adv2",
|
||||
DomainNames: "arr.example.com",
|
||||
ForwardHost: "app",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
AdvancedConfig: string(raw),
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
require.NotNil(t, server)
|
||||
route := server.Routes[0]
|
||||
// First handler should be our headers handler
|
||||
first := route.Handle[0]
|
||||
require.Equal(t, "headers", first["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_LowercaseDomains(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{UUID: "d1", DomainNames: "UPPER.EXAMPLE.COM", ForwardHost: "a", ForwardPort: 80, Enabled: true},
|
||||
}
|
||||
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
|
||||
require.Equal(t, []string{"upper.example.com"}, route.Match[0].Host)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_AdvancedObjectHandler(t *testing.T) {
|
||||
host := models.ProxyHost{
|
||||
UUID: "advobj",
|
||||
DomainNames: "obj.example.com",
|
||||
ForwardHost: "app",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
AdvancedConfig: `{"handler":"headers","response":{"set":{"X-Obj":["1"]}}}`,
|
||||
}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
|
||||
// First handler should be headers
|
||||
first := route.Handle[0]
|
||||
require.Equal(t, "headers", first["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) {
|
||||
// Create a host with a whitelist ACL
|
||||
ipRules := `[{"cidr":"192.168.1.0/24"}]`
|
||||
acl := models.AccessList{ID: 100, Name: "WL", Enabled: true, Type: "whitelist", IPRules: ipRules}
|
||||
host := models.ProxyHost{UUID: "hasacl", DomainNames: "acl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
|
||||
// First handler should be an ACL subroute
|
||||
first := route.Handle[0]
|
||||
require.Equal(t, "subroute", first["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
|
||||
hosts := []models.ProxyHost{{UUID: "u1", DomainNames: ", test.example.com", ForwardHost: "a", ForwardPort: 80, Enabled: true}}
|
||||
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
|
||||
require.Equal(t, []string{"test.example.com"}, route.Match[0].Host)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
|
||||
host := models.ProxyHost{UUID: "adv3", DomainNames: "nohandler.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `{"foo":"bar"}`}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
|
||||
// No headers handler appended; last handler is reverse_proxy
|
||||
last := route.Handle[len(route.Handle)-1]
|
||||
require.Equal(t, "reverse_proxy", last["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_AdvancedUnexpectedJSONStructure(t *testing.T) {
|
||||
host := models.ProxyHost{UUID: "adv4", DomainNames: "struct.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `42`}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
|
||||
// Expect main reverse proxy handler exists but no appended advanced handler
|
||||
last := route.Handle[len(route.Handle)-1]
|
||||
require.Equal(t, "reverse_proxy", last["handler"])
|
||||
}
|
||||
|
||||
// Test buildACLHandler returning nil when an unknown type is supplied but IPRules present
|
||||
func TestBuildACLHandler_UnknownIPTypeReturnsNil(t *testing.T) {
|
||||
acl := &models.AccessList{Type: "custom", IPRules: `[{"cidr":"10.0.0.0/8"}]`}
|
||||
h, err := buildACLHandler(acl)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, h)
|
||||
}
|
||||
138
backend/internal/caddy/config_generate_additional_test.go
Normal file
138
backend/internal/caddy/config_generate_additional_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "h1",
|
||||
DomainNames: "a.example.com",
|
||||
Enabled: true,
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
},
|
||||
}
|
||||
|
||||
// Zerossl provider
|
||||
cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfgZ.Apps.TLS)
|
||||
// Expect only zerossl issuer present
|
||||
issuers := cfgZ.Apps.TLS.Automation.Policies[0].IssuersRaw
|
||||
foundZerossl := false
|
||||
for _, i := range issuers {
|
||||
m := i.(map[string]interface{})
|
||||
if m["module"] == "zerossl" {
|
||||
foundZerossl = true
|
||||
}
|
||||
}
|
||||
require.True(t, foundZerossl)
|
||||
|
||||
// Default/both provider
|
||||
cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false)
|
||||
require.NoError(t, err)
|
||||
issuersBoth := cfgBoth.Apps.TLS.Automation.Policies[0].IssuersRaw
|
||||
// We should have at least 2 issuers (acme + zerossl)
|
||||
require.GreaterOrEqual(t, len(issuersBoth), 2)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_EmptyHostsAndNoFrontend(t *testing.T) {
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
// Should return base config without server routes
|
||||
_, found := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
require.False(t, found)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_SkipsInvalidCustomCert(t *testing.T) {
|
||||
// Create a host with a custom cert missing private key
|
||||
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "CustomCert", Provider: "custom", Certificate: "cert", PrivateKey: ""}
|
||||
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: ptrUint(1)}
|
||||
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false)
|
||||
require.NoError(t, err)
|
||||
// Custom cert missing key should not be in LoadPEM
|
||||
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
|
||||
b, _ := json.Marshal(cfg.Apps.TLS.Certificates)
|
||||
require.NotContains(t, string(b), "CustomCert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) {
|
||||
// Two hosts with same domain - one newer than other should be kept only once
|
||||
h1 := models.ProxyHost{UUID: "h1", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}
|
||||
h2 := models.ProxyHost{UUID: "h2", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.2", ForwardPort: 8081}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false)
|
||||
require.NoError(t, err)
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
// Expect that only one route exists for dup.com (one for the domain)
|
||||
require.GreaterOrEqual(t, len(server.Routes), 1)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) {
|
||||
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "LoadPEM", Provider: "custom", Certificate: "cert", PrivateKey: "key"}
|
||||
host := models.ProxyHost{UUID: "h1", DomainNames: "pem.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: &cert.ID}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg.Apps.TLS)
|
||||
require.NotNil(t, cfg.Apps.TLS.Certificates)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_DefaultAcmeStaging(t *testing.T) {
|
||||
hosts := []models.ProxyHost{{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}}
|
||||
cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true)
|
||||
require.NoError(t, err)
|
||||
// Should include acme issuer with CA staging URL
|
||||
issuers := cfg.Apps.TLS.Automation.Policies[0].IssuersRaw
|
||||
found := false
|
||||
for _, i := range issuers {
|
||||
if m, ok := i.(map[string]interface{}); ok {
|
||||
if m["module"] == "acme" {
|
||||
if _, ok := m["ca"]; ok {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) {
|
||||
// create host with an ACL with invalid JSON to force buildACLHandler to error
|
||||
acl := models.AccessList{ID: 10, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid"}
|
||||
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false)
|
||||
require.NoError(t, err)
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
// Even if ACL handler error occurs, config should still be returned with routes
|
||||
require.NotNil(t, server)
|
||||
require.GreaterOrEqual(t, len(server.Routes), 1)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_SkipHostDomainEmptyAndDisabled(t *testing.T) {
|
||||
disabled := models.ProxyHost{UUID: "h1", Enabled: false, DomainNames: "skip.com", ForwardHost: "127.0.0.1", ForwardPort: 8080}
|
||||
emptyDomain := models.ProxyHost{UUID: "h2", Enabled: true, DomainNames: "", ForwardHost: "127.0.0.1", ForwardPort: 8080}
|
||||
cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false)
|
||||
require.NoError(t, err)
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
// Both hosts should be skipped; only routes from no hosts should be only catch-all if frontend provided
|
||||
if server != nil {
|
||||
// If frontend set, there will be catch-all route only
|
||||
if len(server.Routes) > 0 {
|
||||
// If frontend present, one route will be catch-all; ensure no host-based route exists
|
||||
for _, r := range server.Routes {
|
||||
for _, m := range r.Match {
|
||||
for _, host := range m.Host {
|
||||
require.NotEqual(t, "skip.com", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
backend/internal/caddy/config_generate_test.go
Normal file
42
backend/internal/caddy/config_generate_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateConfig_CustomCertsAndTLS(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "h1",
|
||||
DomainNames: "a.example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
Certificate: &models.SSLCertificate{ID: 1, UUID: "c1", Name: "CustomCert", Provider: "custom", Certificate: "cert", PrivateKey: "key"},
|
||||
CertificateID: ptrUint(1),
|
||||
HSTSEnabled: true,
|
||||
HSTSSubdomains: true,
|
||||
BlockExploits: true,
|
||||
Locations: []models.Location{{Path: "/app", ForwardHost: "127.0.0.1", ForwardPort: 8081}},
|
||||
},
|
||||
}
|
||||
cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
// TLS should be configured
|
||||
require.NotNil(t, cfg.Apps.TLS)
|
||||
// Custom cert load
|
||||
require.NotNil(t, cfg.Apps.TLS.Certificates)
|
||||
// One route for the host (with location) plus catch-all -> at least 2 routes
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
require.GreaterOrEqual(t, len(server.Routes), 2)
|
||||
// Check HSTS header exists in JSON representation
|
||||
b, _ := json.Marshal(cfg)
|
||||
require.Contains(t, string(b), "Strict-Transport-Security")
|
||||
}
|
||||
|
||||
func ptrUint(v uint) *uint { return &v }
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestGenerateConfig_Empty(t *testing.T) {
|
||||
@@ -37,7 +37,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) {
|
||||
require.NotNil(t, config.Apps.HTTP)
|
||||
require.Len(t, config.Apps.HTTP.Servers, 1)
|
||||
|
||||
server := config.Apps.HTTP.Servers["cpm_server"]
|
||||
server := config.Apps.HTTP.Servers["charon_server"]
|
||||
require.NotNil(t, server)
|
||||
require.Contains(t, server.Listen, ":80")
|
||||
require.Contains(t, server.Listen, ":443")
|
||||
@@ -73,7 +73,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) {
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2)
|
||||
require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
|
||||
@@ -91,7 +91,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
route := config.Apps.HTTP.Servers["cpm_server"].Routes[0]
|
||||
route := config.Apps.HTTP.Servers["charon_server"].Routes[0]
|
||||
handler := route.Handle[0]
|
||||
|
||||
// Check WebSocket headers are present
|
||||
@@ -112,7 +112,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) {
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false)
|
||||
require.NoError(t, err)
|
||||
// Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here)
|
||||
require.Empty(t, config.Apps.HTTP.Servers["cpm_server"].Routes)
|
||||
require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_Logging(t *testing.T) {
|
||||
@@ -159,7 +159,7 @@ func TestGenerateConfig_Advanced(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
|
||||
server := config.Apps.HTTP.Servers["cpm_server"]
|
||||
server := config.Apps.HTTP.Servers["charon_server"]
|
||||
require.NotNil(t, server)
|
||||
// Should have 2 routes: 1 for location /api, 1 for main domain
|
||||
require.Len(t, server.Routes, 2)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
// Executor defines an interface for executing shell commands.
|
||||
@@ -102,6 +102,9 @@ func NewImporter(binaryPath string) *Importer {
|
||||
}
|
||||
}
|
||||
|
||||
// forceSplitFallback used in tests to exercise the fallback branch
|
||||
var forceSplitFallback bool
|
||||
|
||||
// ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON.
|
||||
func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) {
|
||||
if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) {
|
||||
@@ -213,7 +216,7 @@ func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
|
||||
dial, _ := upstream["dial"].(string)
|
||||
if dial != "" {
|
||||
hostStr, portStr, err := net.SplitHostPort(dial)
|
||||
if err == nil {
|
||||
if err == nil && !forceSplitFallback {
|
||||
host.ForwardHost = hostStr
|
||||
if _, err := fmt.Sscanf(portStr, "%d", &host.ForwardPort); err != nil {
|
||||
host.ForwardPort = 80
|
||||
|
||||
62
backend/internal/caddy/importer_additional_test.go
Normal file
62
backend/internal/caddy/importer_additional_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestImporter_ExtractHosts_DialWithoutPortDefaultsTo80(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
|
||||
rawJSON := []byte("{\"apps\":{\"http\":{\"servers\":{\"srv0\":{\"routes\":[{\"match\":[{\"host\":[\"nop.example.com\"]}],\"handle\":[{\"handler\":\"reverse_proxy\",\"upstreams\":[{\"dial\":\"example.com\"}]}]}]}}}}}")
|
||||
|
||||
res, err := importer.ExtractHosts(rawJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, res.Hosts, 1)
|
||||
host := res.Hosts[0]
|
||||
assert.Equal(t, "example.com", host.ForwardHost)
|
||||
assert.Equal(t, 80, host.ForwardPort)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_DetectsWebsocketFromHeaders(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
|
||||
rawJSON := []byte("{\"apps\":{\"http\":{\"servers\":{\"srv0\":{\"routes\":[{\"match\":[{\"host\":[\"ws.example.com\"]}],\"handle\":[{\"handler\":\"reverse_proxy\",\"upstreams\":[{\"dial\":\"127.0.0.1:8080\"}],\"headers\":{\"Upgrade\":[\"websocket\"]}}]}]}}}}}")
|
||||
|
||||
res, err := importer.ExtractHosts(rawJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, res.Hosts, 1)
|
||||
host := res.Hosts[0]
|
||||
assert.True(t, host.WebsocketSupport)
|
||||
}
|
||||
|
||||
func TestImporter_ImportFile_ParseOutputInvalidJSON(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{Output: []byte("{invalid"), Err: nil}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = importer.ImportFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestImporter_ImportFile_ExecutorError(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{Output: []byte(""), Err: assert.AnError}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = importer.ImportFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
370
backend/internal/caddy/importer_extra_test.go
Normal file
370
backend/internal/caddy/importer_extra_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImporter_ExtractHosts_TLSConnectionPolicyAndDialWithoutPort(t *testing.T) {
|
||||
// Build a sample Caddy JSON with TLSConnectionPolicies and reverse_proxy with dial host:port and host-only dials
|
||||
cfg := CaddyConfig{
|
||||
Apps: &CaddyApps{
|
||||
HTTP: &CaddyHTTP{
|
||||
Servers: map[string]*CaddyServer{
|
||||
"srv": {
|
||||
Listen: []string{":443"},
|
||||
Routes: []*CaddyRoute{
|
||||
{
|
||||
Match: []*CaddyMatcher{{Host: []string{"example.com"}}},
|
||||
Handle: []*CaddyHandler{
|
||||
{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "app:9000"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Match: []*CaddyMatcher{{Host: []string{"nport.example.com"}}},
|
||||
Handle: []*CaddyHandler{
|
||||
{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "app"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
TLSConnectionPolicies: struct{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(out)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 2)
|
||||
// First host should have scheme https because Listen :443
|
||||
require.Equal(t, "https", res.Hosts[0].ForwardScheme)
|
||||
// second host with dial 'app' should be parsed with default port 80
|
||||
require.Equal(t, 80, res.Hosts[1].ForwardPort)
|
||||
}
|
||||
|
||||
func TestExtractHandlers_Subroute_WithUnsupportedSubhandle(t *testing.T) {
|
||||
// Build a handler with subroute whose handle contains a non-map item
|
||||
h := []*CaddyHandler{
|
||||
{Handler: "subroute", Routes: []interface{}{map[string]interface{}{"handle": []interface{}{"not-a-map", map[string]interface{}{"handler": "reverse_proxy"}}}}},
|
||||
}
|
||||
importer := NewImporter("")
|
||||
res := importer.extractHandlers(h)
|
||||
// Should ignore the non-map and keep the reverse_proxy handler
|
||||
require.Len(t, res, 1)
|
||||
require.Equal(t, "reverse_proxy", res[0].Handler)
|
||||
}
|
||||
|
||||
func TestExtractHandlers_Subroute_WithNonMapRoutes(t *testing.T) {
|
||||
h := []*CaddyHandler{
|
||||
{Handler: "subroute", Routes: []interface{}{"not-a-map"}},
|
||||
}
|
||||
importer := NewImporter("")
|
||||
res := importer.extractHandlers(h)
|
||||
require.Len(t, res, 0)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_UpstreamsNonMapAndWarnings(t *testing.T) {
|
||||
cfg := CaddyConfig{
|
||||
Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"warn.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{"nonnmap"}}, {Handler: "rewrite"}, {Handler: "file_server"}},
|
||||
}},
|
||||
}}}},
|
||||
}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
require.Contains(t, res.Hosts[0].Warnings[0], "Rewrite rules not supported")
|
||||
require.Contains(t, res.Hosts[0].Warnings[1], "File server directives not supported")
|
||||
}
|
||||
|
||||
func TestBackupCaddyfile_ReadFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// original file does not exist
|
||||
_, err := BackupCaddyfile("/does/not/exist", tmp)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestExtractHandlers_Subroute_EmptyAndHandleNotArray(t *testing.T) {
|
||||
// Empty routes array
|
||||
h := []*CaddyHandler{
|
||||
{Handler: "subroute", Routes: []interface{}{}},
|
||||
}
|
||||
importer := NewImporter("")
|
||||
res := importer.extractHandlers(h)
|
||||
require.Len(t, res, 0)
|
||||
|
||||
// Routes with a map but handle is not an array
|
||||
h2 := []*CaddyHandler{
|
||||
{Handler: "subroute", Routes: []interface{}{map[string]interface{}{"handle": "not-an-array"}}},
|
||||
}
|
||||
res2 := importer.extractHandlers(h2)
|
||||
require.Len(t, res2, 0)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_ReverseProxyNoUpstreams(t *testing.T) {
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"noups.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
// No upstreams should leave ForwardHost empty and ForwardPort 0
|
||||
require.Equal(t, "", res.Hosts[0].ForwardHost)
|
||||
require.Equal(t, 0, res.Hosts[0].ForwardPort)
|
||||
}
|
||||
|
||||
func TestBackupCaddyfile_Success(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
originalFile := filepath.Join(tmp, "Caddyfile")
|
||||
data := []byte("original-data")
|
||||
os.WriteFile(originalFile, data, 0644)
|
||||
backupDir := filepath.Join(tmp, "backup")
|
||||
path, err := BackupCaddyfile(originalFile, backupDir)
|
||||
require.NoError(t, err)
|
||||
// Backup file should exist and contain same data
|
||||
b, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, data, b)
|
||||
}
|
||||
|
||||
func TestExtractHandlers_Subroute_WithHeadersUpstreams(t *testing.T) {
|
||||
h := []*CaddyHandler{
|
||||
{Handler: "subroute", Routes: []interface{}{map[string]interface{}{"handle": []interface{}{map[string]interface{}{"handler": "reverse_proxy", "upstreams": []interface{}{map[string]interface{}{"dial": "app:8080"}}, "headers": map[string]interface{}{"Upgrade": []interface{}{"websocket"}}}}}}},
|
||||
}
|
||||
importer := NewImporter("")
|
||||
res := importer.extractHandlers(h)
|
||||
require.Len(t, res, 1)
|
||||
require.Equal(t, "reverse_proxy", res[0].Handler)
|
||||
// Upstreams should be present in extracted handler
|
||||
_, ok := res[0].Upstreams.([]interface{})
|
||||
require.True(t, ok)
|
||||
_, ok = res[0].Headers.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_DuplicateHost(t *testing.T) {
|
||||
cfg := CaddyConfig{
|
||||
Apps: &CaddyApps{
|
||||
HTTP: &CaddyHTTP{
|
||||
Servers: map[string]*CaddyServer{
|
||||
"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"dup.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "one:80"}}}},
|
||||
}},
|
||||
},
|
||||
"srv2": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"dup.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "two:80"}}}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
// Duplicate should be captured in Conflicts
|
||||
require.Len(t, res.Conflicts, 1)
|
||||
require.Equal(t, "dup.example.com", res.Conflicts[0])
|
||||
}
|
||||
|
||||
func TestBackupCaddyfile_WriteFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
originalFile := filepath.Join(tmp, "Caddyfile")
|
||||
os.WriteFile(originalFile, []byte("original"), 0644)
|
||||
// Create backup dir and make it readonly to prevent writing (best-effort)
|
||||
backupDir := filepath.Join(tmp, "backup")
|
||||
os.MkdirAll(backupDir, 0555)
|
||||
_, err := BackupCaddyfile(originalFile, backupDir)
|
||||
// Might error due to write permission; accept both success or failure depending on platform
|
||||
if err != nil {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
entries, _ := os.ReadDir(backupDir)
|
||||
require.True(t, len(entries) > 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_SSLForcedByDomainScheme(t *testing.T) {
|
||||
// Domain contains scheme prefix, which should set SSLForced
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"https://secure.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "one:80"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
require.Equal(t, true, res.Hosts[0].SSLForced)
|
||||
require.Equal(t, "https", res.Hosts[0].ForwardScheme)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_MultipleHostsInMatch(t *testing.T) {
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"m1.example.com", "m2.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "one:80"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 2)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_UpgradeHeaderAsString(t *testing.T) {
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"ws.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "one:80"}}, Headers: map[string]interface{}{"Upgrade": []string{"websocket"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
// Websocket support should be detected after JSON roundtrip
|
||||
require.True(t, res.Hosts[0].WebsocketSupport)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_SscanfFailureOnPort(t *testing.T) {
|
||||
// Trigger net.SplitHostPort success but Sscanf failing
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"sscanf.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "127.0.0.1:eighty"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
// Sscanf should fail and default to port 80
|
||||
require.Equal(t, 80, res.Hosts[0].ForwardPort)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_PartsSscanfFail(t *testing.T) {
|
||||
// Trigger net.SplitHostPort fail but strings.Split parts with non-numeric port
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"parts.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "tcp/127.0.0.1:badport"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
require.Equal(t, 80, res.Hosts[0].ForwardPort)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_PartsEmptyPortField(t *testing.T) {
|
||||
// net.SplitHostPort fails (missing port) but strings.Split returns two parts with empty port
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"emptyparts.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "tcp/127.0.0.1:"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
require.Equal(t, 80, res.Hosts[0].ForwardPort)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_ForceSplitFallback_PartsNumericPort(t *testing.T) {
|
||||
// Force the fallback split behavior to hit len(parts)==2 branch
|
||||
orig := forceSplitFallback
|
||||
forceSplitFallback = true
|
||||
defer func() { forceSplitFallback = orig }()
|
||||
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"forced.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "127.0.0.1:8181"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
require.Equal(t, "127.0.0.1", res.Hosts[0].ForwardHost)
|
||||
require.Equal(t, 8181, res.Hosts[0].ForwardPort)
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts_ForceSplitFallback_PartsSscanfFail(t *testing.T) {
|
||||
// Force the fallback split behavior with non-numeric port to hit Sscanf error branch
|
||||
orig := forceSplitFallback
|
||||
forceSplitFallback = true
|
||||
defer func() { forceSplitFallback = orig }()
|
||||
|
||||
cfg := CaddyConfig{Apps: &CaddyApps{HTTP: &CaddyHTTP{Servers: map[string]*CaddyServer{"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*CaddyRoute{{
|
||||
Match: []*CaddyMatcher{{Host: []string{"forcedfail.example.com"}}},
|
||||
Handle: []*CaddyHandler{{Handler: "reverse_proxy", Upstreams: []interface{}{map[string]interface{}{"dial": "127.0.0.1:notnum"}}}},
|
||||
}},
|
||||
}}}}}
|
||||
b, _ := json.Marshal(cfg)
|
||||
importer := NewImporter("")
|
||||
res, err := importer.ExtractHosts(b)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Hosts, 1)
|
||||
require.Equal(t, 80, res.Hosts[0].ForwardPort)
|
||||
}
|
||||
|
||||
func TestBackupCaddyfile_WriteErrorDeterministic(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
originalFile := filepath.Join(tmp, "Caddyfile")
|
||||
os.WriteFile(originalFile, []byte("original-data"), 0644)
|
||||
backupDir := filepath.Join(tmp, "backup")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
// Determine backup path name the function will use
|
||||
pid := fmt.Sprintf("%d", os.Getpid())
|
||||
// Pre-create a directory at the exact backup path to ensure write fails with EISDIR
|
||||
path := filepath.Join(backupDir, fmt.Sprintf("Caddyfile.%s.backup", pid))
|
||||
os.Mkdir(path, 0755)
|
||||
_, err := BackupCaddyfile(originalFile, backupDir)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -12,7 +12,20 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
// Test hooks to allow overriding OS and JSON functions
|
||||
var (
|
||||
writeFileFunc = os.WriteFile
|
||||
readFileFunc = os.ReadFile
|
||||
removeFileFunc = os.Remove
|
||||
readDirFunc = os.ReadDir
|
||||
statFunc = os.Stat
|
||||
jsonMarshalFunc = json.MarshalIndent
|
||||
// Test hooks for bandaging validation/generation flows
|
||||
generateConfigFunc = GenerateConfig
|
||||
validateConfigFunc = Validate
|
||||
)
|
||||
|
||||
// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
|
||||
@@ -58,13 +71,13 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Generate Caddy config
|
||||
config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging)
|
||||
config, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate config: %w", err)
|
||||
}
|
||||
|
||||
// Validate before applying
|
||||
if err := Validate(config); err != nil {
|
||||
if err := validateConfigFunc(config); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -81,7 +94,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
// Apply to Caddy
|
||||
if err := m.client.Load(ctx, config); err != nil {
|
||||
// Remove the failed snapshot so rollback uses the previous one
|
||||
_ = os.Remove(snapshotPath)
|
||||
_ = removeFileFunc(snapshotPath)
|
||||
|
||||
// Rollback on failure
|
||||
if rollbackErr := m.rollback(ctx); rollbackErr != nil {
|
||||
@@ -113,12 +126,12 @@ func (m *Manager) saveSnapshot(config *Config) (string, error) {
|
||||
filename := fmt.Sprintf("config-%d.json", timestamp)
|
||||
path := filepath.Join(m.configDir, filename)
|
||||
|
||||
configJSON, err := json.MarshalIndent(config, "", " ")
|
||||
configJSON, err := jsonMarshalFunc(config, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, configJSON, 0644); err != nil {
|
||||
if err := writeFileFunc(path, configJSON, 0644); err != nil {
|
||||
return "", fmt.Errorf("write snapshot: %w", err)
|
||||
}
|
||||
|
||||
@@ -134,7 +147,7 @@ func (m *Manager) rollback(ctx context.Context) error {
|
||||
|
||||
// Load most recent snapshot
|
||||
latestSnapshot := snapshots[len(snapshots)-1]
|
||||
configJSON, err := os.ReadFile(latestSnapshot)
|
||||
configJSON, err := readFileFunc(latestSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read snapshot: %w", err)
|
||||
}
|
||||
@@ -154,7 +167,7 @@ func (m *Manager) rollback(ctx context.Context) error {
|
||||
|
||||
// listSnapshots returns all snapshot file paths sorted by modification time.
|
||||
func (m *Manager) listSnapshots() ([]string, error) {
|
||||
entries, err := os.ReadDir(m.configDir)
|
||||
entries, err := readDirFunc(m.configDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config dir: %w", err)
|
||||
}
|
||||
@@ -169,8 +182,8 @@ func (m *Manager) listSnapshots() ([]string, error) {
|
||||
|
||||
// Sort by modification time
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
infoI, _ := os.Stat(snapshots[i])
|
||||
infoJ, _ := os.Stat(snapshots[j])
|
||||
infoI, _ := statFunc(snapshots[i])
|
||||
infoJ, _ := statFunc(snapshots[j])
|
||||
return infoI.ModTime().Before(infoJ.ModTime())
|
||||
})
|
||||
|
||||
@@ -191,7 +204,7 @@ func (m *Manager) rotateSnapshots(keep int) error {
|
||||
// Delete oldest snapshots
|
||||
toDelete := snapshots[:len(snapshots)-keep]
|
||||
for _, path := range toDelete {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if err := removeFileFunc(path); err != nil {
|
||||
return fmt.Errorf("delete snapshot %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
521
backend/internal/caddy/manager_additional_test.go
Normal file
521
backend/internal/caddy/manager_additional_test.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestManager_ListSnapshots_ReadDirError(t *testing.T) {
|
||||
// Use a path that does not exist
|
||||
tmp := t.TempDir()
|
||||
// create manager with a non-existent subdir
|
||||
manager := NewManager(nil, nil, filepath.Join(tmp, "nope"), "", false)
|
||||
_, err := manager.listSnapshots()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManager_RotateSnapshots_NoOp(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
// No snapshots exist; should be no error
|
||||
err := manager.rotateSnapshots(10)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestManager_Rollback_NoSnapshots(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
err := manager.rollback(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no snapshots available")
|
||||
}
|
||||
|
||||
func TestManager_Rollback_UnmarshalError(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Write a non-JSON file with .json extension
|
||||
p := filepath.Join(tmp, "config-123.json")
|
||||
os.WriteFile(p, []byte("not json"), 0644)
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
// Reader error should happen before client.Load
|
||||
err := manager.rollback(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unmarshal snapshot")
|
||||
}
|
||||
|
||||
func TestManager_Rollback_LoadSnapshotFail(t *testing.T) {
|
||||
// Create a valid JSON file and set client to return error for /load
|
||||
tmp := t.TempDir()
|
||||
p := filepath.Join(tmp, "config-123.json")
|
||||
os.WriteFile(p, []byte(`{"apps":{"http":{}}}`), 0644)
|
||||
|
||||
// Mock client that returns error on Load
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
badClient := NewClient(server.URL)
|
||||
manager := NewManager(badClient, nil, tmp, "", false)
|
||||
err := manager.rollback(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "load snapshot")
|
||||
}
|
||||
|
||||
func TestManager_SaveSnapshot_WriteError(t *testing.T) {
|
||||
// Create a file at path to use as configDir, so writes fail
|
||||
tmp := t.TempDir()
|
||||
notDir := filepath.Join(tmp, "file-not-dir")
|
||||
os.WriteFile(notDir, []byte("data"), 0644)
|
||||
manager := NewManager(nil, nil, notDir, "", false)
|
||||
_, err := manager.saveSnapshot(&Config{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "write snapshot")
|
||||
}
|
||||
|
||||
func TestBackupCaddyfile_MkdirAllFailure(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
originalFile := filepath.Join(tmp, "Caddyfile")
|
||||
os.WriteFile(originalFile, []byte("original"), 0644)
|
||||
// Create a file where the backup dir should be to cause MkdirAll to fail
|
||||
badDir := filepath.Join(tmp, "notadir")
|
||||
os.WriteFile(badDir, []byte("data"), 0644)
|
||||
|
||||
_, err := BackupCaddyfile(originalFile, badDir)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// Note: Deletion failure for rotateSnapshots is difficult to reliably simulate across environments
|
||||
// (tests run as root in CI and local dev containers). If needed, add platform-specific tests.
|
||||
|
||||
func TestManager_SaveSnapshot_Success(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
path, err := manager.saveSnapshot(&Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, path)
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_WithSettings(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{\"apps\":{\"http\":{}}}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Create settings for acme email and ssl provider
|
||||
db.Create(&models.Setting{Key: "caddy.acme_email", Value: "admin@example.com"})
|
||||
db.Create(&models.Setting{Key: "caddy.ssl_provider", Value: "zerossl"})
|
||||
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "", false)
|
||||
|
||||
// Create a host
|
||||
host := models.ProxyHost{
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Apply Config
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify config was saved to DB
|
||||
var caddyConfig models.CaddyConfig
|
||||
err = db.First(&caddyConfig).Error
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, caddyConfig.Success)
|
||||
}
|
||||
|
||||
// Skipping rotate snapshot-on-apply warning test — rotation errors are non-fatal and environment
|
||||
// dependent. We cover rotateSnapshots failure separately below.
|
||||
|
||||
func TestManager_RotateSnapshots_ListDirError(t *testing.T) {
|
||||
manager := NewManager(nil, nil, filepath.Join(t.TempDir(), "nope"), "", false)
|
||||
err := manager.rotateSnapshots(10)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManager_RotateSnapshots_DeletesOld(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// create 5 snapshot files with different timestamps
|
||||
for i := 1; i <= 5; i++ {
|
||||
name := fmt.Sprintf("config-%d.json", i)
|
||||
p := filepath.Join(tmp, name)
|
||||
os.WriteFile(p, []byte("{}"), 0644)
|
||||
// tweak mod time
|
||||
os.Chtimes(p, time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
// Keep last 2 snapshots
|
||||
err := manager.rotateSnapshots(2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Ensure only 2 files remain
|
||||
files, _ := os.ReadDir(tmp)
|
||||
var cnt int
|
||||
for _, f := range files {
|
||||
if filepath.Ext(f.Name()) == ".json" {
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, cnt)
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_RotateSnapshotsWarning(t *testing.T) {
|
||||
// Setup DB and Caddy server that accepts load
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Create a host so GenerateConfig produces a config
|
||||
host := models.ProxyHost{DomainNames: "rot.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
// Create manager with a configDir that is not readable (non-existent subdir)
|
||||
tmp := t.TempDir()
|
||||
// Create snapshot files: make the oldest a non-empty directory to force delete error;
|
||||
// generate 11 snapshots so rotateSnapshots(10) will attempt to delete 1
|
||||
d1 := filepath.Join(tmp, "config-1.json")
|
||||
os.MkdirAll(d1, 0755)
|
||||
os.WriteFile(filepath.Join(d1, "inner"), []byte("x"), 0644) // non-empty
|
||||
for i := 2; i <= 11; i++ {
|
||||
os.WriteFile(filepath.Join(tmp, fmt.Sprintf("config-%d.json", i)), []byte("{}"), 0644)
|
||||
}
|
||||
// Set modification times to ensure config-1.json is oldest
|
||||
for i := 1; i <= 11; i++ {
|
||||
p := filepath.Join(tmp, fmt.Sprintf("config-%d.json", i))
|
||||
if i == 1 {
|
||||
p = d1
|
||||
}
|
||||
tmo := time.Now().Add(time.Duration(-i) * time.Minute)
|
||||
os.Chtimes(p, tmo, tmo)
|
||||
}
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmp, "", false)
|
||||
|
||||
// ApplyConfig should succeed even if rotateSnapshots later returns an error
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_LoadFailsAndRollbackFails(t *testing.T) {
|
||||
// Mock Caddy admin API which returns error for /load so ApplyConfig fails
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Create a host so GenerateConfig produces a config
|
||||
host := models.ProxyHost{DomainNames: "fail.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
tmp := t.TempDir()
|
||||
client := NewClient(server.URL)
|
||||
manager := NewManager(client, db, tmp, "", false)
|
||||
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apply failed")
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_SaveSnapshotFails(t *testing.T) {
|
||||
// Setup DB and Caddy server that accepts load
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"savefail")
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Create a host so GenerateConfig produces a config
|
||||
host := models.ProxyHost{DomainNames: "savefail.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
// Create a file where configDir should be to cause saveSnapshot to fail
|
||||
tmp := t.TempDir()
|
||||
filePath := filepath.Join(tmp, "file-not-dir")
|
||||
os.WriteFile(filePath, []byte("data"), 0644)
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, filePath, "", false)
|
||||
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "save snapshot")
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_LoadFailsThenRollbackSucceeds(t *testing.T) {
|
||||
// Create a server that fails the first /load but succeeds on the second /load
|
||||
var callCount int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rollbackok")
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Create a host
|
||||
host := models.ProxyHost{DomainNames: "rb.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
tmp := t.TempDir()
|
||||
client := NewClient(server.URL)
|
||||
manager := NewManager(client, db, tmp, "", false)
|
||||
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apply failed")
|
||||
}
|
||||
|
||||
func TestManager_SaveSnapshot_MarshalError(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
// Stub jsonMarshallFunc to return error
|
||||
orig := jsonMarshalFunc
|
||||
jsonMarshalFunc = func(v interface{}, prefix, indent string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("marshal fail")
|
||||
}
|
||||
defer func() { jsonMarshalFunc = orig }()
|
||||
|
||||
_, err := manager.saveSnapshot(&Config{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManager_RotateSnapshots_DeleteError(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Create three files to remove one
|
||||
for i := 1; i <= 3; i++ {
|
||||
p := filepath.Join(tmp, fmt.Sprintf("config-%d.json", i))
|
||||
os.WriteFile(p, []byte("{}"), 0644)
|
||||
os.Chtimes(p, time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
// Stub removeFileFunc to return error for specific path
|
||||
origRemove := removeFileFunc
|
||||
removeFileFunc = func(p string) error {
|
||||
if filepath.Base(p) == "config-1.json" {
|
||||
return fmt.Errorf("cannot delete")
|
||||
}
|
||||
return origRemove(p)
|
||||
}
|
||||
defer func() { removeFileFunc = origRemove }()
|
||||
|
||||
err := manager.rotateSnapshots(2)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Setup DB - minimal
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"genfail")
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Create a host so ApplyConfig tries to generate config
|
||||
host := models.ProxyHost{DomainNames: "x.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
// stub generateConfigFunc to always return error
|
||||
orig := generateConfigFunc
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool) (*Config, error) {
|
||||
return nil, fmt.Errorf("generate fail")
|
||||
}
|
||||
defer func() { generateConfigFunc = orig }()
|
||||
|
||||
manager := NewManager(nil, db, tmp, "", false)
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "generate config")
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_ValidateFails(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Setup DB - minimal
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"valfail")
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Create a host so ApplyConfig tries to generate config
|
||||
host := models.ProxyHost{DomainNames: "y.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
// Stub validate function to return error
|
||||
orig := validateConfigFunc
|
||||
validateConfigFunc = func(cfg *Config) error { return fmt.Errorf("validation failed stub") }
|
||||
defer func() { validateConfigFunc = orig }()
|
||||
|
||||
// Use a working client so generation succeeds
|
||||
// Mock Caddy admin API that accepts loads
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmp, "", false)
|
||||
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "validation failed")
|
||||
}
|
||||
|
||||
func TestManager_Rollback_ReadFileError(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
manager := NewManager(nil, nil, tmp, "", false)
|
||||
// Create snapshot entries via write
|
||||
p := filepath.Join(tmp, "config-123.json")
|
||||
os.WriteFile(p, []byte(`{"apps":{"http":{}}}`), 0644)
|
||||
// Stub readFileFunc to return error
|
||||
origRead := readFileFunc
|
||||
readFileFunc = func(p string) ([]byte, error) { return nil, fmt.Errorf("read error") }
|
||||
defer func() { readFileFunc = origRead }()
|
||||
|
||||
err := manager.rollback(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read snapshot")
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_RotateSnapshotsWarning_Stderr(t *testing.T) {
|
||||
// Setup minimal DB and client that accepts load
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()+"rotwarn")
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
host := models.ProxyHost{DomainNames: "rotwarn.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}
|
||||
db.Create(&host)
|
||||
|
||||
// Setup Caddy server
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/config/" && r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{" + "\"apps\":{\"http\":{}}}"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// stub readDirFunc to return error to cause rotateSnapshots to fail
|
||||
origReadDir := readDirFunc
|
||||
readDirFunc = func(path string) ([]os.DirEntry, error) { return nil, fmt.Errorf("dir read fail") }
|
||||
defer func() { readDirFunc = origReadDir }()
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, t.TempDir(), "", false)
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
// Should succeed despite rotation warning (non-fatal)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
|
||||
@@ -122,7 +122,20 @@ func ReverseProxyHandler(dial string, enableWS bool, application string) Handler
|
||||
// Application-specific headers for proper client IP forwarding
|
||||
// These are critical for media servers behind tunnels/CGNAT
|
||||
switch application {
|
||||
case "plex", "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden":
|
||||
case "plex":
|
||||
// Pass-through common Plex headers for improved compatibility when proxying
|
||||
setHeaders["X-Plex-Client-Identifier"] = []string{"{http.request.header.X-Plex-Client-Identifier}"}
|
||||
setHeaders["X-Plex-Device"] = []string{"{http.request.header.X-Plex-Device}"}
|
||||
setHeaders["X-Plex-Device-Name"] = []string{"{http.request.header.X-Plex-Device-Name}"}
|
||||
setHeaders["X-Plex-Platform"] = []string{"{http.request.header.X-Plex-Platform}"}
|
||||
setHeaders["X-Plex-Platform-Version"] = []string{"{http.request.header.X-Plex-Platform-Version}"}
|
||||
setHeaders["X-Plex-Product"] = []string{"{http.request.header.X-Plex-Product}"}
|
||||
setHeaders["X-Plex-Token"] = []string{"{http.request.header.X-Plex-Token}"}
|
||||
setHeaders["X-Plex-Version"] = []string{"{http.request.header.X-Plex-Version}"}
|
||||
// Also set X-Real-IP for accurate client IP reporting
|
||||
setHeaders["X-Real-IP"] = []string{"{http.request.remote.host}"}
|
||||
setHeaders["X-Forwarded-Host"] = []string{"{http.request.host}"}
|
||||
case "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden":
|
||||
// X-Real-IP is required by most apps to identify the real client
|
||||
// Caddy already sets X-Forwarded-For and X-Forwarded-Proto by default
|
||||
setHeaders["X-Real-IP"] = []string{"{http.request.remote.host}"}
|
||||
|
||||
43
backend/internal/caddy/types_extra_test.go
Normal file
43
backend/internal/caddy/types_extra_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReverseProxyHandler_PlexAndOthers(t *testing.T) {
|
||||
// Plex should include X-Plex headers and X-Real-IP
|
||||
h := ReverseProxyHandler("app:32400", false, "plex")
|
||||
require.Equal(t, "reverse_proxy", h["handler"])
|
||||
// Assert headers exist
|
||||
if hdrs, ok := h["headers"].(map[string]interface{}); ok {
|
||||
req := hdrs["request"].(map[string]interface{})
|
||||
set := req["set"].(map[string][]string)
|
||||
require.Contains(t, set, "X-Plex-Client-Identifier")
|
||||
require.Contains(t, set, "X-Real-IP")
|
||||
} else {
|
||||
t.Fatalf("expected headers map for plex")
|
||||
}
|
||||
|
||||
// Jellyfin should include X-Real-IP
|
||||
h2 := ReverseProxyHandler("app:8096", true, "jellyfin")
|
||||
require.Equal(t, "reverse_proxy", h2["handler"])
|
||||
if hdrs, ok := h2["headers"].(map[string]interface{}); ok {
|
||||
req := hdrs["request"].(map[string]interface{})
|
||||
set := req["set"].(map[string][]string)
|
||||
require.Contains(t, set, "X-Real-IP")
|
||||
} else {
|
||||
t.Fatalf("expected headers map for jellyfin")
|
||||
}
|
||||
|
||||
// No websocket means no Upgrade header
|
||||
h3 := ReverseProxyHandler("app:80", false, "none")
|
||||
if hdrs, ok := h3["headers"].(map[string]interface{}); ok {
|
||||
if req, ok := hdrs["request"].(map[string]interface{}); ok {
|
||||
if set, ok := req["set"].(map[string][]string); ok {
|
||||
require.NotContains(t, set, "Upgrade")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,16 @@ func Validate(cfg *Config) error {
|
||||
}
|
||||
|
||||
// Validate JSON marshalling works
|
||||
if _, err := json.Marshal(cfg); err != nil {
|
||||
if _, err := jsonMarshalValidate(cfg); err != nil {
|
||||
return fmt.Errorf("config cannot be marshalled to JSON: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// allow tests to override JSON marshalling to simulate errors
|
||||
var jsonMarshalValidate = json.Marshal
|
||||
|
||||
func validateListenAddr(addr string) error {
|
||||
// Strip network type prefix if present (tcp/, udp/)
|
||||
if idx := strings.Index(addr, "/"); idx != -1 {
|
||||
|
||||
84
backend/internal/caddy/validator_additional_test.go
Normal file
84
backend/internal/caddy/validator_additional_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidate_NilConfig(t *testing.T) {
|
||||
err := Validate(nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "config cannot be nil")
|
||||
}
|
||||
|
||||
func TestValidateHandler_MissingHandlerField(t *testing.T) {
|
||||
// Handler without a 'handler' key
|
||||
h := Handler{"foo": "bar"}
|
||||
err := validateHandler(h)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "missing 'handler' field")
|
||||
}
|
||||
|
||||
func TestValidateHandler_UnknownHandlerAllowed(t *testing.T) {
|
||||
// Unknown handler type should be allowed
|
||||
h := Handler{"handler": "custom_handler"}
|
||||
err := validateHandler(h)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateHandler_FileServerAndStaticResponseAllowed(t *testing.T) {
|
||||
h1 := Handler{"handler": "file_server"}
|
||||
err := validateHandler(h1)
|
||||
require.NoError(t, err)
|
||||
|
||||
h2 := Handler{"handler": "static_response"}
|
||||
err = validateHandler(h2)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateRoute_InvalidHandler(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*Route{{
|
||||
Match: []Match{{Host: []string{"test.invalid"}}},
|
||||
Handle: []Handler{{"foo": "bar"}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid handler")
|
||||
}
|
||||
|
||||
func TestValidateListenAddr_InvalidHostName(t *testing.T) {
|
||||
err := validateListenAddr("example.com:80")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid IP address")
|
||||
}
|
||||
|
||||
func TestValidateListenAddr_InvalidPortNonNumeric(t *testing.T) {
|
||||
err := validateListenAddr(":abc")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid port")
|
||||
}
|
||||
|
||||
func TestValidate_MarshalError(t *testing.T) {
|
||||
// stub jsonMarshalValidate to cause Marshal error
|
||||
orig := jsonMarshalValidate
|
||||
jsonMarshalValidate = func(v interface{}) ([]byte, error) { return nil, fmt.Errorf("marshal error") }
|
||||
defer func() { jsonMarshalValidate = orig }()
|
||||
|
||||
cfg := &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{"srv": {Listen: []string{":80"}, Routes: []*Route{{Match: []Match{{Host: []string{"x.com"}}}, Handle: []Handler{{"handler": "file_server"}}}}}}}}}
|
||||
err := Validate(cfg)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "config cannot be marshalled")
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestValidate_EmptyConfig(t *testing.T) {
|
||||
|
||||
98
backend/internal/cerberus/cerberus.go
Normal file
98
backend/internal/cerberus/cerberus.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package cerberus
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
// Cerberus provides a lightweight facade for security checks (WAF, CrowdSec, ACL).
|
||||
type Cerberus struct {
|
||||
cfg config.SecurityConfig
|
||||
db *gorm.DB
|
||||
accessSvc *services.AccessListService
|
||||
}
|
||||
|
||||
// New creates a new Cerberus instance
|
||||
func New(cfg config.SecurityConfig, db *gorm.DB) *Cerberus {
|
||||
return &Cerberus{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
accessSvc: services.NewAccessListService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether Cerberus features are enabled via config or settings.
|
||||
func (c *Cerberus) IsEnabled() bool {
|
||||
if c.cfg.CerberusEnabled {
|
||||
return true
|
||||
}
|
||||
|
||||
// If any of the security modes are explicitly enabled, consider Cerberus enabled.
|
||||
// Treat empty values as disabled to avoid treating zero-values ("") as enabled.
|
||||
if c.cfg.CrowdSecMode == "local" || c.cfg.CrowdSecMode == "remote" || c.cfg.CrowdSecMode == "enabled" {
|
||||
return true
|
||||
}
|
||||
if c.cfg.WAFMode == "enabled" || c.cfg.RateLimitMode == "enabled" || c.cfg.ACLMode == "enabled" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check database setting (runtime toggle) only if db is provided
|
||||
if c.db != nil {
|
||||
var s models.Setting
|
||||
if err := c.db.Where("key = ?", "security.cerberus.enabled").First(&s).Error; err == nil {
|
||||
return strings.EqualFold(s.Value, "true")
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Middleware returns a Gin middleware that enforces Cerberus checks when enabled.
|
||||
func (c *Cerberus) Middleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if !c.IsEnabled() {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// WAF: naive example check - block requests containing <script> in URL
|
||||
if c.cfg.WAFMode == "enabled" {
|
||||
if strings.Contains(ctx.Request.RequestURI, "<script>") {
|
||||
ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "WAF: suspicious payload detected"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ACL: simple per-request evaluation against all access lists if enabled
|
||||
if c.cfg.ACLMode == "enabled" {
|
||||
acls, err := c.accessSvc.List()
|
||||
if err == nil {
|
||||
clientIP := ctx.ClientIP()
|
||||
for _, acl := range acls {
|
||||
if !acl.Enabled {
|
||||
continue
|
||||
}
|
||||
allowed, _, err := c.accessSvc.TestIP(acl.ID, clientIP)
|
||||
if err == nil && !allowed {
|
||||
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Blocked by access control list"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CrowdSec placeholder: integration would check CrowdSec API and apply blocks
|
||||
// (no-op for the moment)
|
||||
|
||||
// Rate limiting placeholder (no-op for the moment)
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
86
backend/internal/cerberus/cerberus_isenabled_test.go
Normal file
86
backend/internal/cerberus/cerberus_isenabled_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package cerberus_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/cerberus"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupDBForTest(t *testing.T) *gorm.DB {
|
||||
dsn := fmt.Sprintf("file:cerberus_isenabled_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestIsEnabled_ConfigTrue(t *testing.T) {
|
||||
cfg := config.SecurityConfig{CerberusEnabled: true}
|
||||
c := cerberus.New(cfg, nil)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_WAFModeEnabled(t *testing.T) {
|
||||
cfg := config.SecurityConfig{WAFMode: "enabled"}
|
||||
c := cerberus.New(cfg, nil)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_ACLModeEnabled(t *testing.T) {
|
||||
cfg := config.SecurityConfig{ACLMode: "enabled"}
|
||||
c := cerberus.New(cfg, nil)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_RateLimitModeEnabled(t *testing.T) {
|
||||
cfg := config.SecurityConfig{RateLimitMode: "enabled"}
|
||||
c := cerberus.New(cfg, nil)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_CrowdSecModeLocal(t *testing.T) {
|
||||
cfg := config.SecurityConfig{CrowdSecMode: "local"}
|
||||
c := cerberus.New(cfg, nil)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_DBSetting(t *testing.T) {
|
||||
db := setupDBForTest(t)
|
||||
// insert setting to database
|
||||
s := models.Setting{Key: "security.cerberus.enabled", Value: "true"}
|
||||
require.NoError(t, db.Create(&s).Error)
|
||||
cfg := config.SecurityConfig{}
|
||||
c := cerberus.New(cfg, db)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_DBSettingCaseInsensitive(t *testing.T) {
|
||||
db := setupDBForTest(t)
|
||||
s := models.Setting{Key: "security.cerberus.enabled", Value: "TrUe"}
|
||||
require.NoError(t, db.Create(&s).Error)
|
||||
cfg := config.SecurityConfig{}
|
||||
c := cerberus.New(cfg, db)
|
||||
require.True(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_DBSettingFalse(t *testing.T) {
|
||||
db := setupDBForTest(t)
|
||||
s := models.Setting{Key: "security.cerberus.enabled", Value: "false"}
|
||||
require.NoError(t, db.Create(&s).Error)
|
||||
cfg := config.SecurityConfig{}
|
||||
c := cerberus.New(cfg, db)
|
||||
require.False(t, c.IsEnabled())
|
||||
}
|
||||
|
||||
func TestIsEnabled_DefaultFalse(t *testing.T) {
|
||||
cfg := config.SecurityConfig{}
|
||||
c := cerberus.New(cfg, nil)
|
||||
require.False(t, c.IsEnabled())
|
||||
}
|
||||
148
backend/internal/cerberus/cerberus_middleware_test.go
Normal file
148
backend/internal/cerberus/cerberus_middleware_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package cerberus_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/cerberus"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *gorm.DB {
|
||||
dsn := fmt.Sprintf("file:cerberus_middleware_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.AccessList{}, &models.AccessListRule{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMiddleware_WAFBlocksPayload(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
cfg := config.SecurityConfig{WAFMode: "enabled"}
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
// Setup gin context
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
|
||||
// Create a request containing "<script>" in the URI (should trigger WAF)
|
||||
req := httptest.NewRequest(http.MethodGet, "/?q=<script>", nil)
|
||||
req.RequestURI = "/?q=<script>"
|
||||
ctx.Request = req
|
||||
|
||||
// call middleware
|
||||
mw := c.Middleware()
|
||||
mw(ctx)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestMiddleware_ACLBlocksClientIP(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
cfg := config.SecurityConfig{ACLMode: "enabled"}
|
||||
// Create an ACL that blocks 8.8.8.8
|
||||
ruleJSON := `[ { "cidr": "8.8.8.8/32", "description": "block" } ]`
|
||||
acl := &models.AccessList{Name: "Block8", Type: "blacklist", IPRules: ruleJSON, Enabled: true}
|
||||
require.NoError(t, db.Create(acl).Error)
|
||||
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
// Setup gin context with remote address 8.8.8.8
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "8.8.8.8:1234"
|
||||
ctx.Request = req
|
||||
|
||||
mw := c.Middleware()
|
||||
mw(ctx)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestMiddleware_ACLAllowsClientIP(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
cfg := config.SecurityConfig{ACLMode: "enabled"}
|
||||
// Create a whitelist that allows 8.8.8.8
|
||||
ruleJSON := `[ { "cidr": "8.8.8.8/32", "description": "allow" } ]`
|
||||
acl := &models.AccessList{Name: "Allow8", Type: "whitelist", IPRules: ruleJSON, Enabled: true}
|
||||
require.NoError(t, db.Create(acl).Error)
|
||||
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
// Setup gin context with remote address 8.8.8.8 (allowed)
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "8.8.8.8:1234"
|
||||
ctx.Request = req
|
||||
|
||||
mw := c.Middleware()
|
||||
mw(ctx)
|
||||
// Should not block - middleware did not abort
|
||||
require.False(t, ctx.IsAborted())
|
||||
}
|
||||
|
||||
func TestMiddleware_NotEnabledSkips(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
// All modes disabled by default
|
||||
cfg := config.SecurityConfig{}
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "1.2.3.4:1234"
|
||||
ctx.Request = req
|
||||
|
||||
mw := c.Middleware()
|
||||
mw(ctx)
|
||||
require.False(t, ctx.IsAborted())
|
||||
}
|
||||
|
||||
func TestMiddleware_WAFPassesWithNoPayload(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
cfg := config.SecurityConfig{WAFMode: "enabled"}
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodGet, "/?q=safe", nil)
|
||||
req.RequestURI = "/?q=safe"
|
||||
ctx.Request = req
|
||||
|
||||
mw := c.Middleware()
|
||||
mw(ctx)
|
||||
require.False(t, ctx.IsAborted())
|
||||
}
|
||||
|
||||
func TestMiddleware_ACLDisabledDoesNotBlock(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
cfg := config.SecurityConfig{ACLMode: "enabled"}
|
||||
// Create a disabled ACL that would block 8.8.8.8 (but it's disabled)
|
||||
ruleJSON := `[ { "cidr": "8.8.8.8/32", "description": "block" } ]`
|
||||
acl := &models.AccessList{Name: "Block8_Disabled", Type: "blacklist", IPRules: ruleJSON, Enabled: false}
|
||||
require.NoError(t, db.Create(acl).Error)
|
||||
|
||||
c := cerberus.New(cfg, db)
|
||||
|
||||
// Setup gin context with remote address 8.8.8.8
|
||||
w := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(w)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "8.8.8.8:1234"
|
||||
ctx.Request = req
|
||||
|
||||
mw := c.Middleware()
|
||||
mw(ctx)
|
||||
// Disabled ACL should not block
|
||||
require.False(t, ctx.IsAborted())
|
||||
}
|
||||
50
backend/internal/cerberus/cerberus_test.go
Normal file
50
backend/internal/cerberus/cerberus_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package cerberus_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/cerberus"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
// Use a unique in-memory database per test run to avoid shared state.
|
||||
dsn := fmt.Sprintf("file:cerberus_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
// migrate only the Setting model used by Cerberus
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCerberus_IsEnabled_ConfigTrue(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
cfg := config.SecurityConfig{CerberusEnabled: true}
|
||||
cerb := cerberus.New(cfg, db)
|
||||
require.True(t, cerb.IsEnabled())
|
||||
}
|
||||
|
||||
func TestCerberus_IsEnabled_DBSetting(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
// We're storing 'security.cerberus.enabled' key
|
||||
db.Create(&models.Setting{Key: "security.cerberus.enabled", Value: "true"})
|
||||
cfg := config.SecurityConfig{CerberusEnabled: false}
|
||||
cerb := cerberus.New(cfg, db)
|
||||
require.True(t, cerb.IsEnabled())
|
||||
}
|
||||
|
||||
func TestCerberus_IsEnabled_Disabled(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
cfg := config.SecurityConfig{CerberusEnabled: false}
|
||||
cerb := cerberus.New(cfg, db)
|
||||
t.Logf("cfg: %+v", cfg)
|
||||
t.Logf("IsEnabled() -> %v", cerb.IsEnabled())
|
||||
require.False(t, cerb.IsEnabled())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user