fix(workflows): replace invalid semantic-version action with fallback script

This commit is contained in:
CI
2025-11-29 01:34:52 +00:00
parent ebd8a8e92b
commit ce8a51e6c7
180 changed files with 9019 additions and 1036 deletions

View File

@@ -46,6 +46,7 @@ backend/cmd/api/data/*.db
*.sqlite
*.sqlite3
cpm.db
charon.db
# IDE
.vscode/

View File

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

View File

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

View File

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

View File

@@ -103,4 +103,5 @@ jobs:
}
}
env:
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
CPMP_TOKEN: ${{ secrets.CPMP_TOKEN }}

View File

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

View File

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

View File

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

View File

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

@@ -39,6 +39,7 @@ backend/data/*.db
backend/data/**/*.db
backend/cmd/api/data/*.db
cpm.db
charon.db
# IDE
.idea/

View File

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

View File

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

View File

@@ -1 +1 @@
0.2.0-beta.1
0.3.0

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": "",
"envManager": "ms-python.python:venv",
"packageManager": "ms-python.python:pip"
}
]
}

6
.vscode/tasks.json vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

1658
backend/caddy.html Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

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

View 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