Compare commits

..

23 Commits

Author SHA1 Message Date
github-action-benchmark c7941326c6 add Go Benchmark (go) benchmark result for 54382f62a1 2026-02-04 10:36:02 +00:00
github-action-benchmark 1953545c45 add Go Benchmark (go) benchmark result for ab0bc15740 2026-02-03 15:56:08 +00:00
github-action-benchmark c8f55d42e5 add Go Benchmark (go) benchmark result for 49f24e8915 2026-01-30 16:08:27 +00:00
github-action-benchmark 93511b93d3 add Go Benchmark (go) benchmark result for 86b74e73c4 2026-01-30 03:39:31 +00:00
github-action-benchmark 5aa52e521f add Go Benchmark (go) benchmark result for c70a65f52b 2026-01-16 04:26:28 +00:00
github-action-benchmark ec6e30c5f8 add Go Benchmark (go) benchmark result for 2f4e5a6920 2026-01-15 20:56:01 +00:00
github-action-benchmark ff0de66515 add Go Benchmark (go) benchmark result for c7b8dca974 2026-01-12 06:03:51 +00:00
github-action-benchmark 95e047bc5e add Go Benchmark (go) benchmark result for 03f079ce82 2026-01-01 04:21:23 +00:00
github-action-benchmark 3db0a7b693 add Go Benchmark (go) benchmark result for 42fcb0f3ac 2025-12-21 15:23:19 +00:00
github-action-benchmark bcdce306b8 add Go Benchmark (go) benchmark result for 4cec3595e2 2025-12-18 18:40:57 +00:00
github-action-benchmark b8a3ea9d51 add Go Benchmark (go) benchmark result for ec19803750 2025-12-18 00:28:28 +00:00
github-action-benchmark 0114642aa8 add Go Benchmark (go) benchmark result for 370bcfc125 2025-12-17 15:17:42 +00:00
github-action-benchmark 82c69194b7 add Go Benchmark (go) benchmark result for 7e31a9c41a 2025-12-17 14:23:27 +00:00
github-action-benchmark 222a3df766 add Go Benchmark (go) benchmark result for 1a5bc81c6c 2025-12-17 12:50:03 +00:00
github-action-benchmark cef53f2951 add Go Benchmark (go) benchmark result for 0a4ea58110 2025-12-16 14:35:12 +00:00
github-action-benchmark 31e5052f80 add Go Benchmark (go) benchmark result for 5d569b7724 2025-12-15 06:40:21 +00:00
github-action-benchmark 3844d8de02 add Go Benchmark (go) benchmark result for 7106efa94a 2025-12-14 07:13:19 +00:00
github-action-benchmark 57ab01114f add Go Benchmark (go) benchmark result for 3fd85ce34f 2025-12-14 01:08:05 +00:00
github-action-benchmark 1b3867b316 add Go Benchmark (go) benchmark result for 6deb5eb9f2 2025-12-14 00:52:19 +00:00
github-action-benchmark 0eeb1aef0f add Go Benchmark (go) benchmark result for 65443a1464 2025-12-14 00:38:30 +00:00
github-action-benchmark 52f0a2ec3d add Go Benchmark (go) benchmark result for 8c24016b39 2025-12-13 01:37:12 +00:00
github-action-benchmark d04ed0cd98 add Go Benchmark (go) benchmark result for f04750f16c 2025-12-09 16:19:52 +00:00
GitHub Actions 00b20c5b67 Initial gh-pages commit 2025-12-03 20:02:39 +00:00
397 changed files with 10969 additions and 62155 deletions
-40
View File
@@ -1,40 +0,0 @@
# Codecov configuration - require 75% overall coverage by default
# Adjust target as needed
coverage:
status:
project:
default:
target: 75%
threshold: 0%
# Fail CI if Codecov upload/report indicates a problem
require_ci_to_pass: yes
# Exclude folders from Codecov
ignore:
- "**/tests/*"
- "**/test/*"
- "**/__tests__/*"
- "**/test_*.go"
- "**/*_test.go"
- "**/*.test.ts"
- "**/*.test.tsx"
- "docs/*"
- ".github/*"
- "scripts/*"
- "tools/*"
- "frontend/node_modules/*"
- "frontend/dist/*"
- "frontend/coverage/*"
- "backend/cmd/seed/*"
- "backend/cmd/api/*"
- "backend/data/*"
- "backend/coverage/*"
- "backend/*.cover"
- "backend/*.out"
- "backend/internal/services/docker_service.go"
- "backend/internal/api/handlers/docker_handler.go"
- "codeql-db/*"
- "*.sarif"
- "*.md"
-113
View File
@@ -1,113 +0,0 @@
# Version control
.git
.gitignore
.github/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
ENV/
.pytest_cache/
.coverage
*.cover
.hypothesis/
htmlcov/
*.egg-info/
# Node/Frontend build artifacts
frontend/node_modules/
frontend/coverage/
frontend/coverage.out
frontend/dist/
frontend/.vite/
frontend/*.tsbuildinfo
frontend/frontend/
# Go/Backend
backend/coverage.txt
backend/*.out
backend/*.cover
backend/coverage/
backend/coverage.*.out
backend/coverage_*.out
backend/package.json
backend/package-lock.json
# Databases (runtime)
backend/data/*.db
backend/data/**/*.db
backend/cmd/api/data/*.db
*.sqlite
*.sqlite3
cpm.db
charon.db
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
.trivy_logs/
*.log
logs/
# Environment
.env
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db
# Documentation
docs/
*.md
!README.md
# Docker
docker-compose*.yml
**/Dockerfile.*
# CI/CD
.github/
.pre-commit-config.yaml
.codecov.yml
.goreleaser.yaml
# GoReleaser artifacts
dist/
# Scripts
scripts/
tools/
create_issues.sh
cookies.txt
# Testing artifacts
coverage.out
*.cover
*.crdownload
# Project Documentation
ACME_STAGING_IMPLEMENTATION.md
ARCHITECTURE_PLAN.md
BULK_ACL_FEATURE.md
DOCKER_TASKS.md
DOCUMENTATION_POLISH_SUMMARY.md
GHCR_MIGRATION_SUMMARY.md
ISSUE_*_IMPLEMENTATION.md
PHASE_*_SUMMARY.md
PROJECT_BOARD_SETUP.md
PROJECT_PLANNING.md
SECURITY_IMPLEMENTATION_PLAN.md
VERSIONING_IMPLEMENTATION.md
-14
View File
@@ -1,14 +0,0 @@
# These are supported funding model platforms
github: Wikid82
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: Wikid82
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-93
View File
@@ -1,93 +0,0 @@
name: 🏗️ Alpha Feature
description: Create an issue for an Alpha milestone feature
title: "[ALPHA] "
labels: ["alpha", "feature"]
body:
- type: markdown
attributes:
value: |
## Alpha Milestone Feature
Features that are part of the core foundation and initial release.
- type: dropdown
id: priority
attributes:
label: Priority
description: How critical is this feature?
options:
- Critical (Blocking, must-have)
- High (Important, should have)
- Medium (Nice to have)
- Low (Future enhancement)
validations:
required: true
- type: input
id: issue_number
attributes:
label: Planning Issue Number
description: Reference number from PROJECT_PLANNING.md (e.g., Issue #5)
placeholder: "Issue #"
validations:
required: false
- type: textarea
id: description
attributes:
label: Feature Description
description: What should this feature do?
placeholder: Describe the feature in detail
validations:
required: true
- type: textarea
id: tasks
attributes:
label: Implementation Tasks
description: List of tasks to complete this feature
placeholder: |
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
value: |
- [ ]
validations:
required: true
- type: textarea
id: acceptance
attributes:
label: Acceptance Criteria
description: How do we know this feature is complete?
placeholder: |
- [ ] Criteria 1
- [ ] Criteria 2
value: |
- [ ]
validations:
required: true
- type: checkboxes
id: categories
attributes:
label: Categories
description: Select all that apply
options:
- label: Backend
- label: Frontend
- label: Database
- label: Caddy Integration
- label: Security
- label: SSL/TLS
- label: UI/UX
- label: Deployment
- label: Documentation
- type: textarea
id: technical_notes
attributes:
label: Technical Notes
description: Any technical considerations or dependencies?
placeholder: Libraries, APIs, or other issues that need to be completed first
validations:
required: false
@@ -1,118 +0,0 @@
name: 📊 Beta Monitoring Feature
description: Create an issue for a Beta milestone monitoring/logging feature
title: "[BETA] [MONITORING] "
labels: ["beta", "feature", "monitoring"]
body:
- type: markdown
attributes:
value: |
## Beta Monitoring & Logging Feature
Features related to observability, logging, and system monitoring.
- type: dropdown
id: priority
attributes:
label: Priority
description: How critical is this monitoring feature?
options:
- Critical (Essential for operations)
- High (Important visibility)
- Medium (Enhanced monitoring)
- Low (Nice-to-have metrics)
validations:
required: true
- type: dropdown
id: monitoring_type
attributes:
label: Monitoring Type
description: What aspect of monitoring?
options:
- Dashboards & Statistics
- Log Viewing & Search
- Alerting & Notifications
- CrowdSec Dashboard
- Analytics Integration
- Health Checks
- Performance Metrics
validations:
required: true
- type: input
id: issue_number
attributes:
label: Planning Issue Number
description: Reference number from PROJECT_PLANNING.md (e.g., Issue #23)
placeholder: "Issue #"
validations:
required: false
- type: textarea
id: description
attributes:
label: Feature Description
description: What monitoring/logging capability should this provide?
placeholder: Describe what users will be able to see or do
validations:
required: true
- type: textarea
id: metrics
attributes:
label: Metrics & Data Points
description: What data will be collected and displayed?
placeholder: |
- Metric 1: Description
- Metric 2: Description
validations:
required: false
- type: textarea
id: tasks
attributes:
label: Implementation Tasks
description: List of tasks to complete this feature
placeholder: |
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
value: |
- [ ]
validations:
required: true
- type: textarea
id: acceptance
attributes:
label: Acceptance Criteria
description: How do we verify this monitoring feature works?
placeholder: |
- [ ] Data displays correctly
- [ ] Updates in real-time
- [ ] Performance is acceptable
value: |
- [ ]
validations:
required: true
- type: checkboxes
id: categories
attributes:
label: Implementation Areas
description: Select all that apply
options:
- label: Backend (Data collection)
- label: Frontend (UI/Charts)
- label: Database (Storage)
- label: Real-time Updates (WebSocket)
- label: External Integration (GoAccess, CrowdSec)
- label: Documentation Required
- type: textarea
id: ui_design
attributes:
label: UI/UX Considerations
description: Describe the user interface requirements
placeholder: Layout, charts, filters, export options, etc.
validations:
required: false
@@ -1,116 +0,0 @@
name: 🔐 Beta Security Feature
description: Create an issue for a Beta milestone security feature
title: "[BETA] [SECURITY] "
labels: ["beta", "feature", "security"]
body:
- type: markdown
attributes:
value: |
## Beta Security Feature
Advanced security features for the beta release.
- type: dropdown
id: priority
attributes:
label: Priority
description: How critical is this security feature?
options:
- Critical (Essential security control)
- High (Important protection)
- Medium (Additional hardening)
- Low (Nice-to-have security enhancement)
validations:
required: true
- type: dropdown
id: security_category
attributes:
label: Security Category
description: What type of security feature is this?
options:
- Authentication & Access Control
- Threat Protection
- SSL/TLS Management
- Monitoring & Logging
- Web Application Firewall
- Rate Limiting
- IP Access Control
validations:
required: true
- type: input
id: issue_number
attributes:
label: Planning Issue Number
description: Reference number from PROJECT_PLANNING.md (e.g., Issue #15)
placeholder: "Issue #"
validations:
required: false
- type: textarea
id: description
attributes:
label: Feature Description
description: What security capability should this provide?
placeholder: Describe the security feature and its purpose
validations:
required: true
- type: textarea
id: threat_model
attributes:
label: Threat Model
description: What threats does this feature mitigate?
placeholder: |
- Threat 1: Description and severity
- Threat 2: Description and severity
validations:
required: false
- type: textarea
id: tasks
attributes:
label: Implementation Tasks
description: List of tasks to complete this feature
placeholder: |
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
value: |
- [ ]
validations:
required: true
- type: textarea
id: acceptance
attributes:
label: Acceptance Criteria
description: How do we verify this security control works?
placeholder: |
- [ ] Security test 1
- [ ] Security test 2
value: |
- [ ]
validations:
required: true
- type: checkboxes
id: special_labels
attributes:
label: Special Categories
description: Select all that apply
options:
- label: SSO (Single Sign-On)
- label: WAF (Web Application Firewall)
- label: CrowdSec Integration
- label: Plus Feature (Premium)
- label: Requires Documentation
- type: textarea
id: security_testing
attributes:
label: Security Testing Plan
description: How will you test this security feature?
placeholder: Describe testing approach, tools, and scenarios
validations:
required: false
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -1,97 +0,0 @@
name: ⚙️ General Feature
description: Create a feature request for any milestone
title: "[FEATURE] "
labels: ["feature"]
body:
- type: markdown
attributes:
value: |
## Feature Request
Request a new feature or enhancement for CaddyProxyManager+
- type: dropdown
id: milestone
attributes:
label: Target Milestone
description: Which release should this be part of?
options:
- Alpha (Core foundation)
- Beta (Advanced features)
- Post-Beta (Future enhancements)
- Unsure (Help me decide)
validations:
required: true
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature?
options:
- Critical
- High
- Medium
- Low
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem Statement
description: What problem does this feature solve?
placeholder: Describe the use case or pain point
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: How should this feature work?
placeholder: Describe your ideal implementation
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: What other approaches could solve this?
placeholder: List alternative solutions you've thought about
validations:
required: false
- type: textarea
id: user_story
attributes:
label: User Story
description: Describe this from a user's perspective
placeholder: "As a [user type], I want to [action] so that [benefit]"
validations:
required: false
- type: checkboxes
id: categories
attributes:
label: Feature Categories
description: Select all that apply
options:
- label: Authentication/Authorization
- label: Security
- label: SSL/TLS
- label: Monitoring/Logging
- label: UI/UX
- label: Performance
- label: Documentation
- label: API
- label: Plus Feature (Premium)
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other information, screenshots, or examples?
placeholder: Add links, mockups, or references
validations:
required: false
-51
View File
@@ -1,51 +0,0 @@
# 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.
- **Single Backend Source**: All backend code MUST reside in `backend/`.
- **No Python**: This is a Go (Backend) + React/TypeScript (Frontend) project. Do not introduce Python scripts or requirements.
## 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 `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.
## Backend Workflow
- Run locally with `cd backend && go run ./cmd/api`; run tests with `go test ./...` (see `proxy_host_handler_test.go` for the in-memory SQLite/Gin harness pattern).
- Handlers return structured errors using `gin.H{"error": "message"}` and standard HTTP codes—mirror the `ProxyHostHandler` lifecycle for new CRUD endpoints.
- UUIDs (`github.com/google/uuid`) are generated server-side and exposed as `uuid` fields; clients never send numeric IDs.
- Query lists sorted by `updated_at desc` (see `.Order("updated_at desc")` in `List`); match that ordering for user-visible collections.
- Long-running work must respect the graceful shutdown flow in `server.Run(ctx)`—avoid background goroutines that ignore the context.
## Frontend Workflow
- **Location**: Always work within `frontend/`.
- **Stack**: React 18 + Vite + TypeScript + TanStack Query (React Query).
- **State Management**: Use `src/hooks/use*.ts` wrapping React Query. Do not use raw `useEffect` for data fetching.
- **API Layer**: Create typed API clients in `src/api/*.ts` that wrap `client.ts`.
- **Development**: Run `cd frontend && npm run dev`. Vite proxies `/api` to `http://localhost:8080`.
- **Components**: Screens live in `src/pages`. Reusable UI in `src/components`.
- **Forms**: Use local `useState` for form fields, submit via `useMutation` from custom hooks, then `invalidateQueries` on success.
## Cross-Cutting Notes
- Run the backend before the frontend; React Query expects the exact JSON produced by GORM tags (snake_case), so keep API and UI field names aligned.
- When adding models, update both `internal/models` and the `AutoMigrate` call inside `internal/api/routes/routes.go`; register new Gin routes right after migrations for clarity.
- Tests belong beside handlers (`*_test.go`); reuse the `setupTestRouter` helper structure (in-memory SQLite, Gin router, httptest requests) for fast feedback.
- **Testing Requirement**: All new code (features, bug fixes, refactors) MUST include accompanying unit tests. Ensure tests cover happy paths and error conditions.
- **Ignore Files**: When creating new file types, directories, or build artifacts, ALWAYS check and update `.gitignore`, `.dockerignore`, and `.codecov.yml` to ensure they are properly excluded or included as required.
- The root `Dockerfile` builds the Go binary and the React static assets (multi-stage build).
- Branch from `feature/**` and target `development`.
## Documentation
- **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/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:`.
- **Triggering Builds**: To ensure a new Docker image is built (e.g., for testing on VPS), use `feat:`, `fix:`, or `perf:` prefixes.
- **Beta Branch**: The `feature/beta-release` branch is configured to ALWAYS build, overriding the skip logic.
-26
View File
@@ -1,26 +0,0 @@
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
-72
View File
@@ -1,72 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommits",
":separateMultipleMajorReleases",
"helpers:pinGitHubActionDigests"
],
"baseBranches": ["development"],
"timezone": "UTC",
"dependencyDashboard": true,
"prConcurrentLimit": 10,
"prHourlyLimit": 5,
"labels": ["dependencies"],
"rebaseWhen": "conflicted",
"vulnerabilityAlerts": { "enabled": true },
"schedule": ["every weekday"],
"rangeStrategy": "bump",
"packageRules": [
{
"description": "Automerge safe patch updates",
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"description": "Frontend npm: automerge minor for devDependencies",
"matchManagers": ["npm"],
"matchDepTypes": ["devDependencies"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true,
"labels": ["dependencies", "npm"]
},
{
"description": "Backend Go modules",
"matchManagers": ["gomod"],
"labels": ["dependencies", "go"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": false
},
{
"description": "GitHub Actions updates",
"matchManagers": ["github-actions"],
"labels": ["dependencies", "github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"description": "Docker: keep Caddy within v2 (no automatic jump to v3)",
"matchManagers": ["dockerfile"],
"matchPackageNames": ["caddy"],
"allowedVersions": "<3.0.0",
"labels": ["dependencies", "docker"],
"automerge": true,
"extractVersion": "^(?<version>\\d+\\.\\d+\\.\\d+)",
"versioning": "semver"
},
{
"description": "Group non-breaking npm minor/patch",
"matchManagers": ["npm"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "npm minor/patch",
"prPriority": -1
},
{
"description": "Group docker base minor/patch",
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "docker base updates",
"prPriority": -1
}
]
}
-32
View File
@@ -1,32 +0,0 @@
name: Auto-add issues and PRs to Project
on:
issues:
types: [opened, reopened]
pull_request:
types: [opened, reopened]
jobs:
add-to-project:
runs-on: ubuntu-latest
steps:
- name: Determine project URL presence
id: project_check
run: |
if [ -n "${{ secrets.PROJECT_URL }}" ]; then
echo "has_project=true" >> $GITHUB_OUTPUT
else
echo "has_project=false" >> $GITHUB_OUTPUT
fi
- name: Add issue or PR to project
if: steps.project_check.outputs.has_project == 'true'
uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
continue-on-error: true
with:
project-url: ${{ secrets.PROJECT_URL }}
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
- name: Skip summary
if: steps.project_check.outputs.has_project == 'false'
run: echo "PROJECT_URL secret missing; skipping project assignment." >> $GITHUB_STEP_SUMMARY
-17
View File
@@ -1,17 +0,0 @@
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 }}
-74
View File
@@ -1,74 +0,0 @@
name: Auto-label Issues
on:
issues:
types: [opened, edited]
jobs:
auto-label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Auto-label based on title and body
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const issue = context.payload.issue;
const title = issue.title.toLowerCase();
const body = issue.body ? issue.body.toLowerCase() : '';
const labels = [];
// Priority detection
if (title.includes('[critical]') || body.includes('priority: critical')) {
labels.push('critical');
} else if (title.includes('[high]') || body.includes('priority: high')) {
labels.push('high');
} else if (title.includes('[medium]') || body.includes('priority: medium')) {
labels.push('medium');
} else if (title.includes('[low]') || body.includes('priority: low')) {
labels.push('low');
}
// Milestone detection
if (title.includes('[alpha]') || body.includes('milestone: alpha')) {
labels.push('alpha');
} else if (title.includes('[beta]') || body.includes('milestone: beta')) {
labels.push('beta');
} else if (title.includes('[post-beta]') || body.includes('milestone: post-beta')) {
labels.push('post-beta');
}
// Category detection
if (title.includes('architecture') || body.includes('architecture')) labels.push('architecture');
if (title.includes('backend') || body.includes('backend')) labels.push('backend');
if (title.includes('frontend') || body.includes('frontend')) labels.push('frontend');
if (title.includes('security') || body.includes('security')) labels.push('security');
if (title.includes('ssl') || title.includes('tls') || body.includes('certificate')) labels.push('ssl');
if (title.includes('sso') || body.includes('single sign-on')) labels.push('sso');
if (title.includes('waf') || body.includes('web application firewall')) labels.push('waf');
if (title.includes('crowdsec') || body.includes('crowdsec')) labels.push('crowdsec');
if (title.includes('caddy') || body.includes('caddy')) labels.push('caddy');
if (title.includes('database') || body.includes('database')) labels.push('database');
if (title.includes('ui') || title.includes('interface')) labels.push('ui');
if (title.includes('docker') || title.includes('deployment')) labels.push('deployment');
if (title.includes('monitoring') || title.includes('logging')) labels.push('monitoring');
if (title.includes('documentation') || title.includes('docs')) labels.push('documentation');
if (title.includes('test') || body.includes('testing')) labels.push('testing');
if (title.includes('performance') || body.includes('optimization')) labels.push('performance');
if (title.includes('plus') || body.includes('premium feature')) labels.push('plus');
// Feature detection
if (title.includes('feature') || body.includes('feature request')) labels.push('feature');
// Only add labels if we detected any
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Added labels: ${labels.join(', ')}`);
}
-53
View File
@@ -1,53 +0,0 @@
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 }}
-52
View File
@@ -1,52 +0,0 @@
name: Go Benchmark
on:
push:
branches:
- main
- development
paths:
- 'backend/**'
pull_request:
branches:
- main
- development
paths:
- 'backend/**'
workflow_dispatch:
permissions:
contents: write
deployments: write
jobs:
benchmark:
name: Performance Regression Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.4'
cache-dependency-path: backend/go.sum
- name: Run Benchmark
working-directory: backend
run: go test -bench=. -benchmem ./... | tee output.txt
- name: Store Benchmark Result
uses: benchmark-action/github-action-benchmark@v1
with:
name: Go Benchmark
tool: 'go'
output-file-path: backend/output.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# Show alert with commit comment on detection of performance regression
alert-threshold: '150%'
comment-on-alert: true
fail-on-alert: false
# Enable Job Summary for PRs
summary-always: true
-62
View File
@@ -1,62 +0,0 @@
name: Monitor Caddy Major Release
on:
schedule:
- cron: '17 7 * * 1' # Mondays at 07:17 UTC
workflow_dispatch: {}
permissions:
contents: read
issues: write
jobs:
check-caddy-major:
runs-on: ubuntu-latest
steps:
- name: Check for Caddy v3 and open issue
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const upstream = { owner: 'caddyserver', repo: 'caddy' };
const { data: releases } = await github.rest.repos.listReleases({
...upstream,
per_page: 50,
});
const latestV3 = releases.find(r => /^v3\./.test(r.tag_name));
if (!latestV3) {
core.info('No Caddy v3 release detected.');
return;
}
const issueTitle = `Track upgrade to Caddy v3 (${latestV3.tag_name})`;
const { data: existing } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
if (existing.some(i => i.title === issueTitle)) {
core.info('Issue already exists — nothing to do.');
return;
}
const body = [
'Caddy v3 has been released upstream and detected by the scheduled monitor.',
'',
`Detected release: ${latestV3.tag_name} (${latestV3.html_url})`,
'',
'- Create a feature branch to evaluate the v3 migration.',
'- Review breaking changes and update Docker base images/workflows.',
'- Validate Trivy scans and update any policies as needed.',
'',
'Current policy: remain on latest 2.x until v3 is validated.'
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: issueTitle,
body,
});
-77
View File
@@ -1,77 +0,0 @@
name: Upload Coverage to Codecov (Push only)
on:
push:
branches:
- main
- development
- 'feature/**'
permissions:
contents: read
jobs:
backend-codecov:
name: Backend Codecov Upload
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.25.4'
cache-dependency-path: backend/go.sum
- name: Run Go tests
working-directory: backend
env:
CGO_ENABLED: 1
run: |
go test -race -v -coverprofile=coverage.out ./... 2>&1 | tee test-output.txt
exit ${PIPESTATUS[0]}
- name: Upload backend coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./backend/coverage.out
flags: backend
fail_ci_if_error: true
frontend-codecov:
name: Frontend Codecov Upload
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '24.11.1'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Run frontend tests and coverage
working-directory: ${{ github.workspace }}
run: |
bash scripts/frontend-test-coverage.sh 2>&1 | tee frontend/test-output.txt
exit ${PIPESTATUS[0]}
- name: Upload frontend coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
directory: ./frontend/coverage
flags: frontend
fail_ci_if_error: true
-53
View File
@@ -1,53 +0,0 @@
name: CodeQL - Analyze
on:
push:
branches: [ main, development, 'feature/**' ]
pull_request:
branches: [ main, development ]
schedule:
- cron: '0 3 * * 1'
permissions:
contents: read
security-events: write
actions: read
pull-requests: read
jobs:
analyze:
name: CodeQL analysis (${{ matrix.language }})
runs-on: ubuntu-latest
# Skip forked PRs where CPMP_TOKEN lacks security-events permissions
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
permissions:
contents: read
security-events: write
actions: read
pull-requests: read
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript-typescript' ]
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Initialize CodeQL
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4
with:
languages: ${{ matrix.language }}
- name: Setup Go
if: matrix.language == 'go'
uses: actions/setup-go@v4
with:
go-version: '1.25.4'
- name: Autobuild
uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4
with:
category: "/language:${{ matrix.language }}"
-78
View File
@@ -1,78 +0,0 @@
name: Create Project Labels
# This workflow only runs manually to set up labels
on:
workflow_dispatch:
jobs:
create-labels:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Create all project labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const labels = [
// Priority labels
{ name: 'critical', color: 'B60205', description: 'Must have for the release, blocks other work' },
{ name: 'high', color: 'D93F0B', description: 'Important feature, should be included' },
{ name: 'medium', color: 'FBCA04', description: 'Nice to have, can be deferred' },
{ name: 'low', color: '0E8A16', description: 'Future enhancement, not urgent' },
// Milestone labels
{ name: 'alpha', color: '5319E7', description: 'Part of initial alpha release' },
{ name: 'beta', color: '0052CC', description: 'Part of beta release' },
{ name: 'post-beta', color: '006B75', description: 'Post-beta enhancement' },
// Category labels
{ name: 'architecture', color: 'C5DEF5', description: 'System design and structure' },
{ name: 'backend', color: '1D76DB', description: 'Server-side code' },
{ name: 'frontend', color: '5EBEFF', description: 'UI/UX code' },
{ name: 'feature', color: 'A2EEEF', description: 'New functionality' },
{ name: 'security', color: 'EE0701', description: 'Security-related' },
{ name: 'ssl', color: 'F9D0C4', description: 'SSL/TLS certificates' },
{ name: 'sso', color: 'D4C5F9', description: 'Single Sign-On' },
{ name: 'waf', color: 'B60205', description: 'Web Application Firewall' },
{ name: 'crowdsec', color: 'FF6B6B', description: 'CrowdSec integration' },
{ name: 'caddy', color: '1F6FEB', description: 'Caddy-specific' },
{ name: 'database', color: '006B75', description: 'Database-related' },
{ name: 'ui', color: '7057FF', description: 'User interface' },
{ name: 'deployment', color: '0E8A16', description: 'Docker, installation' },
{ name: 'monitoring', color: 'FEF2C0', description: 'Logging and statistics' },
{ name: 'documentation', color: '0075CA', description: 'Docs and guides' },
{ name: 'testing', color: 'BFD4F2', description: 'Test suite' },
{ name: 'performance', color: 'EDEDED', description: 'Optimization' },
{ name: 'community', color: 'D876E3', description: 'Community building' },
{ name: 'plus', color: 'FFD700', description: 'Premium/"Plus" feature' },
{ name: 'enterprise', color: '8B4513', description: 'Enterprise-grade feature' }
];
for (const label of labels) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
color: label.color,
description: label.description
});
console.log(`✓ Created label: ${label.name}`);
} catch (error) {
if (error.status === 422) {
console.log(`⚠ Label already exists: ${label.name}`);
// Update the label if it exists
await github.rest.issues.updateLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label.name,
color: label.color,
description: label.description
});
console.log(`✓ Updated label: ${label.name}`);
} else {
console.error(`✗ Error creating label ${label.name}:`, error.message);
}
}
}
-23
View File
@@ -1,23 +0,0 @@
name: Docker Lint
on:
push:
branches: [ main, development, 'feature/**' ]
paths:
- 'Dockerfile'
pull_request:
branches: [ main, development ]
paths:
- 'Dockerfile'
jobs:
hadolint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Hadolint
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
failure-threshold: warning
-297
View File
@@ -1,297 +0,0 @@
name: Docker Build, Publish & Test
on:
push:
branches:
- main
- development
- feature/beta-release
# Note: Tags are handled by release-goreleaser.yml to avoid duplicate builds
pull_request:
branches:
- main
- development
- feature/beta-release
workflow_dispatch:
workflow_call:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/charon
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
security-events: write
outputs:
skip_build: ${{ steps.skip.outputs.skip_build }}
digest: ${{ steps.build-and-push.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Normalize image name
run: |
echo "IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Determine skip condition
id: skip
env:
ACTOR: ${{ github.actor }}
EVENT: ${{ github.event_name }}
HEAD_MSG: ${{ github.event.head_commit.message }}
REF: ${{ github.ref }}
run: |
should_skip=false
pr_title=""
if [ "$EVENT" = "pull_request" ]; then
pr_title=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH" 2>/dev/null || echo '')
fi
if [ "$ACTOR" = "renovate[bot]" ]; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
# Always build on beta-release branch to ensure artifacts for testing
if [[ "$REF" == "refs/heads/feature/beta-release" ]]; then
should_skip=false
echo "Force building on beta-release branch"
fi
echo "skip_build=$should_skip" >> $GITHUB_OUTPUT
- name: Set up QEMU
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Resolve Caddy base digest
if: steps.skip.outputs.skip_build != 'true'
id: caddy
run: |
docker pull caddy:2-alpine
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: ${{ env.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels)
if: steps.skip.outputs.skip_build != 'true'
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/development' }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/feature/beta-release' }}
type=raw,value=pr-${{ github.ref_name }},enable=${{ github.event_name == 'pull_request' }}
type=sha,format=short,enable=${{ github.event_name != 'pull_request' }}
- name: Build and push Docker image
if: steps.skip.outputs.skip_build != 'true'
id: build-and-push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.meta.outputs.version }}
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VCS_REF=${{ github.sha }}
CADDY_IMAGE=${{ steps.caddy.outputs.image }}
- name: Run Trivy scan (table output)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '0'
continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
id: trivy
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
continue-on-error: true
- name: Check Trivy SARIF exists
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
id: trivy-check
run: |
if [ -f trivy-results.sarif ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Upload Trivy results
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with:
sarif_file: 'trivy-results.sarif'
token: ${{ secrets.GITHUB_TOKEN }}
- name: Create summary
if: steps.skip.outputs.skip_build != 'true'
run: |
echo "## 🎉 Docker Image Built Successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
echo "- **Registry**: GitHub Container Registry (ghcr.io)" >> $GITHUB_STEP_SUMMARY
echo "- **Repository**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
test-image:
name: Test Docker Image
needs: build-and-push
runs-on: ubuntu-latest
if: needs.build-and-push.outputs.skip_build != 'true' && github.event_name != 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Normalize image name
run: |
raw="${{ github.repository_owner }}/${{ github.event.repository.name }}"
echo "IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Determine image tag
id: tag
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
else
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: ${{ 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 charon-test-net
- name: Run Upstream Service (whoami)
run: |
docker run -d \
--name whoami \
--network charon-test-net \
traefik/whoami
- name: Run Charon Container
run: |
docker run -d \
--name test-container \
--network charon-test-net \
-p 8080:8080 \
-p 80:80 \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
- name: Run Integration Test
run: ./scripts/integration-test.sh
- name: Check container logs
if: always()
run: docker logs test-container
- name: Stop container
if: always()
run: |
docker stop test-container whoami || true
docker rm test-container whoami || true
docker network rm charon-test-net || true
- name: Create test summary
if: always()
run: |
echo "## 🧪 Docker Image Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Integration Test**: ${{ job.status == 'success' && '✅ Passed' || '❌ Failed' }}" >> $GITHUB_STEP_SUMMARY
trivy-pr-app-only:
name: Trivy (PR) - App-only
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build image locally for PR
run: |
docker build -t charon:pr-${{ github.sha }} .
- name: Extract `charon` binary from image
run: |
CONTAINER=$(docker create charon:pr-${{ github.sha }})
docker cp ${CONTAINER}:/app/charon ./charon_binary || true
docker rm ${CONTAINER} || true
- name: Run Trivy filesystem scan on `charon` (fail PR on HIGH/CRITICAL)
run: |
docker run --rm -v $HOME/.cache/trivy:/root/.cache/trivy -v $PWD:/workdir aquasec/trivy:latest fs --exit-code 1 --severity CRITICAL,HIGH /workdir/charon_binary
shell: bash
-353
View File
@@ -1,353 +0,0 @@
name: Deploy Documentation to GitHub Pages
on:
push:
branches:
- main # Deploy docs when pushing to main
paths:
- 'docs/**' # Only run if docs folder changes
- 'README.md' # Or if README changes
- '.github/workflows/docs.yml' # Or if this workflow changes
workflow_dispatch: # Allow manual trigger
# Sets permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
name: Build Documentation
runs-on: ubuntu-latest
steps:
# Step 1: Get the code
- name: 📥 Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
# Step 2: Set up Node.js (for building any JS-based doc tools)
- name: 🔧 Set up Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
node-version: '24.11.1'
# Step 3: Create a beautiful docs site structure
- name: 📝 Build documentation site
run: |
# Create output directory
mkdir -p _site
# Copy all markdown files
cp README.md _site/
cp -r docs _site/
# Create a simple HTML index that looks nice
cat > _site/index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Charon - Documentation</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
:root {
--primary: #1d4ed8;
--primary-hover: #1e40af;
}
body {
background-color: #0f172a;
color: #e2e8f0;
}
header {
background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 100%);
padding: 3rem 0;
text-align: center;
margin-bottom: 2rem;
}
header h1 {
color: white;
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
header p {
color: #e0e7ff;
font-size: 1.25rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.card h3 {
color: #60a5fa;
margin-top: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card p {
color: #cbd5e1;
margin-bottom: 1rem;
}
.card a {
color: #60a5fa;
text-decoration: none;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.card a:hover {
color: #93c5fd;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
margin-left: 0.5rem;
}
.badge-beginner {
background: #10b981;
color: white;
}
.badge-advanced {
background: #f59e0b;
color: white;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
footer {
text-align: center;
padding: 3rem 0;
color: #64748b;
border-top: 1px solid #334155;
margin-top: 4rem;
}
</style>
</head>
<body>
<header>
<h1>🚀 Charon</h1>
<p>Make your websites easy to reach - No coding required!</p>
</header>
<div class="container">
<section>
<h2>👋 Welcome!</h2>
<p style="font-size: 1.1rem; color: #cbd5e1;">
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>
<h2 style="margin-top: 3rem;">📚 Getting Started</h2>
<div class="grid">
<div class="card">
<h3>🏠 Getting Started Guide <span class="badge badge-beginner">Start Here</span></h3>
<p>Your first setup in just 5 minutes! We'll walk you through everything step by step.</p>
<a href="docs/getting-started.html">Read the Guide →</a>
</div>
<div class="card">
<h3>📖 README <span class="badge badge-beginner">Essential</span></h3>
<p>Learn what the app does, how to install it, and see examples of what you can build.</p>
<a href="README.html">Read More →</a>
</div>
<div class="card">
<h3>📥 Import Guide</h3>
<p>Already using Caddy? Learn how to bring your existing configuration into the app.</p>
<a href="docs/import-guide.html">Import Your Configs →</a>
</div>
</div>
<h2 style="margin-top: 3rem;">🔧 Developer Documentation</h2>
<div class="grid">
<div class="card">
<h3>🔌 API Reference <span class="badge badge-advanced">Advanced</span></h3>
<p>Complete REST API documentation with examples in JavaScript and Python.</p>
<a href="docs/api.html">View API Docs →</a>
</div>
<div class="card">
<h3>💾 Database Schema <span class="badge badge-advanced">Advanced</span></h3>
<p>Understand how data is stored, relationships, and backup strategies.</p>
<a href="docs/database-schema.html">View Schema →</a>
</div>
<div class="card">
<h3>✨ Contributing Guide</h3>
<p>Want to help make this better? Learn how to contribute code, docs, or ideas.</p>
<a href="CONTRIBUTING.html">Start Contributing →</a>
</div>
</div>
<h2 style="margin-top: 3rem;">📋 All Documentation</h2>
<div class="card">
<h3>📚 Documentation Index</h3>
<p>Browse all available documentation organized by topic and skill level.</p>
<a href="docs/index.html">View Full Index →</a>
</div>
<h2 style="margin-top: 3rem;">🆘 Need Help?</h2>
<div class="card" style="background: #1e3a8a; border-color: #1e40af;">
<h3 style="color: #dbeafe;">Get Support</h3>
<p style="color: #bfdbfe;">
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/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/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/charon"
style="background: white; color: #1e40af; padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none;">
⭐ View on GitHub
</a>
</div>
</div>
</div>
<footer>
<p>Built with ❤️ by <a href="https://github.com/Wikid82" style="color: #60a5fa;">@Wikid82</a></p>
<p>Made for humans, not just techies!</p>
</footer>
</body>
</html>
EOF
# Convert markdown files to HTML using a simple converter
npm install -g marked
# Convert each markdown file
for file in _site/docs/*.md; do
if [ -f "$file" ]; then
filename=$(basename "$file" .md)
marked "$file" -o "_site/docs/${filename}.html" --gfm
fi
done
# Convert README and CONTRIBUTING
marked _site/README.md -o _site/README.html --gfm
if [ -f "CONTRIBUTING.md" ]; then
cp CONTRIBUTING.md _site/
marked _site/CONTRIBUTING.md -o _site/CONTRIBUTING.html --gfm
fi
# Add simple styling to all HTML files
for html_file in _site/*.html _site/docs/*.html; do
if [ -f "$html_file" ] && [ "$html_file" != "_site/index.html" ]; then
# Add a header with navigation to each page
temp_file="${html_file}.tmp"
cat > "$temp_file" << 'HEADER'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Caddy Proxy Manager Plus - Documentation</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body { background-color: #0f172a; color: #e2e8f0; }
nav { background: #1e293b; padding: 1rem; margin-bottom: 2rem; }
nav a { color: #60a5fa; margin-right: 1rem; text-decoration: none; }
nav a:hover { color: #93c5fd; }
main { max-width: 900px; margin: 0 auto; padding: 2rem; }
a { color: #60a5fa; }
code { background: #1e293b; color: #fbbf24; padding: 0.2rem 0.4rem; border-radius: 4px; }
pre { background: #1e293b; padding: 1rem; border-radius: 8px; overflow-x: auto; }
pre code { background: none; padding: 0; }
</style>
</head>
<body>
<nav>
<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
# Append original content
cat "$html_file" >> "$temp_file"
# Add footer
cat >> "$temp_file" << 'FOOTER'
</main>
<footer style="text-align: center; padding: 2rem; color: #64748b;">
<p>Caddy Proxy Manager Plus - Built with ❤️ for the community</p>
</footer>
</body>
</html>
FOOTER
mv "$temp_file" "$html_file"
fi
done
echo "✅ Documentation site built successfully!"
# Step 4: Upload the built site
- name: 📤 Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
path: '_site'
deploy:
name: Deploy to GitHub Pages
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
# Deploy to GitHub Pages
- name: 🚀 Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
# Create a summary
- name: 📋 Create deployment summary
run: |
echo "## 🎉 Documentation Deployed!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Your documentation is now live at:" >> $GITHUB_STEP_SUMMARY
echo "🔗 ${{ steps.deployment.outputs.page_url }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📚 What's Included" >> $GITHUB_STEP_SUMMARY
echo "- Getting Started Guide" >> $GITHUB_STEP_SUMMARY
echo "- Complete README" >> $GITHUB_STEP_SUMMARY
echo "- API Documentation" >> $GITHUB_STEP_SUMMARY
echo "- Database Schema" >> $GITHUB_STEP_SUMMARY
echo "- Import Guide" >> $GITHUB_STEP_SUMMARY
echo "- Contributing Guidelines" >> $GITHUB_STEP_SUMMARY
-107
View File
@@ -1,107 +0,0 @@
name: Propagate Changes Between Branches
on:
push:
branches:
- main
- development
permissions:
contents: write
pull-requests: write
jobs:
propagate:
name: Create PR to synchronize branches
runs-on: ubuntu-latest
if: github.actor != 'github-actions[bot]' && github.event.pusher != null
steps:
- name: Set up Node (for github-script)
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
node-version: '24.11.1'
- name: Propagate Changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const currentBranch = context.ref.replace('refs/heads/', '');
async function createPR(src, base) {
if (src === base) return;
core.info(`Checking propagation from ${src} to ${base}...`);
// Check for existing open PRs
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${src}`,
base: base,
});
if (pulls.length > 0) {
core.info(`Existing PR found for ${src} -> ${base}. Skipping.`);
return;
}
// Compare commits to see if src is ahead of base
try {
const compare = await github.rest.repos.compareCommits({
owner: context.repo.owner,
repo: context.repo.repo,
base: base,
head: src,
});
// If src is not ahead, nothing to merge
if (compare.data.ahead_by === 0) {
core.info(`${src} is not ahead of ${base}. No propagation needed.`);
return;
}
} catch (error) {
// If base branch doesn't exist, etc.
core.warning(`Error comparing ${src} to ${base}: ${error.message}`);
return;
}
// Create PR
try {
const pr = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Propagate changes from ${src} into ${base}`,
head: src,
base: base,
body: `Automated PR to propagate changes from ${src} into ${base}.\n\nTriggered by push to ${currentBranch}.`,
});
core.info(`Created PR #${pr.data.number} to merge ${src} into ${base}`);
} catch (error) {
core.warning(`Failed to create PR from ${src} to ${base}: ${error.message}`);
}
}
if (currentBranch === 'main') {
// Main -> Development
await createPR('main', 'development');
} else if (currentBranch === 'development') {
// Development -> Feature branches
const branches = await github.paginate(github.rest.repos.listBranches, {
owner: context.repo.owner,
repo: context.repo.repo,
});
const featureBranches = branches
.map(b => b.name)
.filter(name => name.startsWith('feature/'));
core.info(`Found ${featureBranches.length} feature branches: ${featureBranches.join(', ')}`);
for (const featureBranch of featureBranches) {
await createPR('development', featureBranch);
}
}
env:
CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
CPMP_TOKEN: ${{ secrets.CPMP_TOKEN }}
-120
View File
@@ -1,120 +0,0 @@
name: Quality Checks
on:
push:
branches: [ main, development, 'feature/**' ]
pull_request:
branches: [ main, development ]
jobs:
backend-quality:
name: Backend (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Go
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
with:
go-version: '1.25.4'
cache-dependency-path: backend/go.sum
- name: Run Go tests
id: go-tests
working-directory: backend
env:
CGO_ENABLED: 1
run: |
go test -race -v -coverprofile=coverage.out ./... 2>&1 | tee test-output.txt
exit ${PIPESTATUS[0]}
- name: Go Test Summary
if: always()
working-directory: backend
run: |
echo "## 🔧 Backend Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.go-tests.outcome }}" == "success" ]; then
echo "✅ **All tests passed**" >> $GITHUB_STEP_SUMMARY
PASS_COUNT=$(grep -c "^--- PASS" test-output.txt || echo "0")
echo "- Tests passed: $PASS_COUNT" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failed Tests:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "^--- FAIL|FAIL\s+github" test-output.txt || echo "See logs for details"
grep -E "^--- FAIL|FAIL\s+github" test-output.txt >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
# Codecov upload moved to `codecov-upload.yml` which is push-only.
- 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:
version: latest
working-directory: backend
args: --timeout=5m
continue-on-error: true
frontend-quality:
name: Frontend (React)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: '24.11.1'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Run frontend tests and coverage
id: frontend-tests
working-directory: ${{ github.workspace }}
run: |
bash scripts/frontend-test-coverage.sh 2>&1 | tee frontend/test-output.txt
exit ${PIPESTATUS[0]}
- name: Frontend Test Summary
if: always()
working-directory: frontend
run: |
echo "## ⚛️ Frontend Test Results" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.frontend-tests.outcome }}" == "success" ]; then
echo "✅ **All tests passed**" >> $GITHUB_STEP_SUMMARY
# Extract test counts from vitest output
if grep -q "Tests:" test-output.txt; then
grep "Tests:" test-output.txt | tail -1 >> $GITHUB_STEP_SUMMARY
fi
else
echo "❌ **Tests failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failed Tests:" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# Extract failed test info from vitest output
grep -E "FAIL|✕|×|AssertionError|Error:" test-output.txt | head -30 >> $GITHUB_STEP_SUMMARY || echo "See logs for details" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
# Codecov upload moved to `codecov-upload.yml` which is push-only.
- 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
continue-on-error: true
-58
View File
@@ -1,58 +0,0 @@
name: Release (GoReleaser)
on:
push:
tags:
- 'v*'
permissions:
contents: write
packages: write
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
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.4'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24.11.1'
- 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
- name: Install Cross-Compilation Tools (Zig)
uses: goto-bus-stop/setup-zig@v2
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
# CGO settings are handled in .goreleaser.yaml via Zig
-37
View File
@@ -1,37 +0,0 @@
name: Renovate
on:
schedule:
- cron: '0 5 * * *' # daily 05:00 EST
workflow_dispatch:
permissions:
contents: write
pull-requests: write
issues: write
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
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: ${{ env.RENOVATE_TOKEN }}
env:
LOG_LEVEL: info
-103
View File
@@ -1,103 +0,0 @@
name: "Prune Renovate Branches"
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *' # daily at 03:00 UTC
pull_request:
types: [closed] # also run when any PR is closed (makes pruning near-real-time)
permissions:
contents: write # required to delete branch refs
pull-requests: read
jobs:
prune:
runs-on: ubuntu-latest
concurrency:
group: prune-renovate-branches
cancel-in-progress: true
env:
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: ${{ env.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const branchPrefix = (process.env.BRANCH_PREFIX || 'renovate/').replace(/^refs\/heads\//, '');
const refPrefix = `heads/${branchPrefix}`; // e.g. "heads/renovate/"
core.info(`Searching for refs with prefix: ${refPrefix}`);
// List matching refs (branches) under the prefix
let refs;
try {
refs = await github.rest.git.listMatchingRefs({
owner,
repo,
ref: refPrefix
});
} catch (err) {
core.info(`No matching refs or API error: ${err.message}`);
refs = { data: [] };
}
for (const r of refs.data) {
const fullRef = r.ref; // "refs/heads/renovate/..."
const branchName = fullRef.replace('refs/heads/', '');
core.info(`Evaluating branch: ${branchName}`);
// Find PRs for this branch (head = "owner:branch")
const prs = await github.rest.pulls.list({
owner,
repo,
head: `${owner}:${branchName}`,
state: 'all',
per_page: 100
});
let shouldDelete = false;
if (!prs.data || prs.data.length === 0) {
core.info(`No PRs found for ${branchName} — marking for deletion.`);
shouldDelete = true;
} else {
// If none of the PRs are open, safe to delete
const hasOpen = prs.data.some(p => p.state === 'open');
if (!hasOpen) {
core.info(`All PRs for ${branchName} are closed — marking for deletion.`);
shouldDelete = true;
} else {
core.info(`Open PR(s) exist for ${branchName} — skipping deletion.`);
}
}
if (shouldDelete) {
try {
await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${branchName}`
});
core.info(`Deleted branch: ${branchName}`);
} catch (delErr) {
core.warning(`Failed to delete ${branchName}: ${delErr.message}`);
}
}
}
- name: Done
run: echo "Prune run completed."
-121
View File
@@ -1,121 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
ENV/
.pytest_cache/
.coverage
*.cover
.hypothesis/
htmlcov/
# Node/Frontend
node_modules/
frontend/node_modules/
backend/node_modules/
frontend/dist/
frontend/coverage/
frontend/.vite/
frontend/*.tsbuildinfo
# Go/Backend
backend/api
backend/*.out
backend/*.cover
backend/coverage/
backend/coverage.*.out
backend/coverage_*.out
# Databases
*.db
*.sqlite
*.sqlite3
backend/data/*.db
backend/data/**/*.db
backend/cmd/api/data/*.db
cpm.db
charon.db
# IDE
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
.trivy_logs
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment
.env
.env.*
!.env.example
# OS
Thumbs.db
*.xcf
# Caddy
backend/data/caddy/
# Docker
docker-compose.override.yml
# GoReleaser
dist/
# Testing
coverage/
coverage.out
*.xml
.trivy_logs/
.trivy_logs/trivy-report.txt
backend/coverage.txt
# CodeQL
codeql-db/
codeql-results.sarif
**.sarif
codeql-results-js.sarif
codeql-results-go.sarif
*.crdownload
.vscode/launch.json
# More CodeQL/analysis artifacts and DBs
codeql-db-*/
codeql-db-js/
codeql-db-go/
codeql-*.sarif
.codeql/
.codeql/**
# Scripts (project-specific)
create_issues.sh
cookies.txt
# Project Documentation (keep important docs, ignore implementation notes)
ACME_STAGING_IMPLEMENTATION.md
ARCHITECTURE_PLAN.md
BULK_ACL_FEATURE.md
DOCKER_TASKS.md
DOCUMENTATION_POLISH_SUMMARY.md
GHCR_MIGRATION_SUMMARY.md
ISSUE_*_IMPLEMENTATION.md
PHASE_*_SUMMARY.md
PROJECT_BOARD_SETUP.md
PROJECT_PLANNING.md
SECURITY_IMPLEMENTATION_PLAN.md
VERSIONING_IMPLEMENTATION.md
backend/internal/api/handlers/import_handler.go.bak
docker-compose.local.yml
-125
View File
@@ -1,125 +0,0 @@
version: 2
project_name: charon
builds:
- id: linux
dir: backend
main: ./cmd/api
binary: charon
env:
- CGO_ENABLED=1
- CC=zig cc -target {{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-linux-gnu
- CXX=zig c++ -target {{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-linux-gnu
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -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: charon
env:
- CGO_ENABLED=1
- CC=zig cc -target x86_64-windows-gnu
- CXX=zig c++ -target x86_64-windows-gnu
goos:
- windows
goarch:
- amd64
ldflags:
- -s -w
- -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: charon
env:
- CGO_ENABLED=1
- CC=zig cc -target {{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-macos-gnu
- CXX=zig c++ -target {{ if eq .Arch "amd64" }}x86_64{{ else }}aarch64{{ end }}-macos-gnu
goos:
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -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
id: nix
builds:
- linux
- darwin
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}
files:
- LICENSE
- README.md
- format: zip
id: windows
builds:
- windows
name_template: >-
{{ .ProjectName }}_
{{- .Version }}_
{{- .Os }}_
{{- .Arch }}
files:
- LICENSE
- README.md
nfpms:
- id: packages
builds:
- linux
package_name: charon
vendor: Charon
homepage: https://github.com/Wikid82/charon
maintainer: Wikid82
description: "Charon - A powerful reverse proxy manager"
license: MIT
formats:
- deb
- rpm
contents:
- src: ./backend/data/
dst: /var/lib/charon/data/
type: dir
- src: ./frontend/dist/
dst: /usr/share/charon/frontend/
type: dir
dependencies:
- libc6
- ca-certificates
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ .Tag }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
-103
View File
@@ -1,103 +0,0 @@
repos:
- repo: local
hooks:
- id: python-compile
name: python compile check
entry: tools/python_compile_check.sh
language: script
files: ".*\\.py$"
pass_filenames: false
always_run: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: end-of-file-fixer
exclude: '^(frontend/(coverage|dist|node_modules|\.vite)/|.*\.tsbuildinfo$)'
- id: trailing-whitespace
exclude: '^(frontend/(coverage|dist|node_modules|\.vite)/|.*\.tsbuildinfo$)'
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=2500']
- repo: local
hooks:
- id: dockerfile-check
name: dockerfile validation
entry: tools/dockerfile_check.sh
language: script
files: "Dockerfile.*"
pass_filenames: true
- id: go-test-coverage
name: Go Test Coverage
entry: scripts/go-test-coverage.sh
language: script
files: '\.go$'
pass_filenames: false
verbose: true
- id: go-vet
name: Go Vet
entry: bash -c 'cd backend && go vet ./...'
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
# Run manually with: pre-commit run golangci-lint --all-files
- id: go-test-race
name: Go Test Race (Manual)
entry: bash -c 'cd backend && go test -race ./...'
language: system
files: '\.go$'
pass_filenames: false
stages: [manual] # Only runs when explicitly called
- id: golangci-lint
name: GolangCI-Lint (Manual)
entry: bash -c 'cd backend && docker run --rm -v $(pwd):/app:ro -w /app golangci/golangci-lint:latest golangci-lint run -v'
language: system
files: '\.go$'
pass_filenames: false
stages: [manual] # Only runs when explicitly called
- id: hadolint
name: Hadolint Dockerfile Check (Manual)
entry: bash -c 'docker run --rm -i hadolint/hadolint < Dockerfile'
language: system
files: 'Dockerfile'
pass_filenames: false
stages: [manual] # Only runs when explicitly called
- id: frontend-type-check
name: Frontend TypeScript Check
entry: bash -c 'cd frontend && npm run type-check'
language: system
files: '^frontend/.*\.(ts|tsx)$'
pass_filenames: false
- id: frontend-lint
name: Frontend Lint (Fix)
entry: bash -c 'cd frontend && npm run lint -- --fix'
language: system
files: '^frontend/.*\.(ts|tsx|js|jsx)$'
pass_filenames: false
- id: frontend-test-coverage
name: Frontend Test Coverage
entry: scripts/frontend-test-coverage.sh
language: script
files: '^frontend/.*\.(ts|tsx|js|jsx)$'
pass_filenames: false
verbose: true
- id: security-scan
name: Security Vulnerability Scan (Manual)
entry: scripts/security-scan.sh
language: script
files: '(\.go$|go\.mod$|go\.sum$)'
pass_filenames: false
verbose: true
stages: [manual] # Only runs when explicitly called
-4
View File
@@ -1,4 +0,0 @@
version: 1
exclude:
- frontend/dist/**
- frontend/node_modules/**
-1
View File
@@ -1 +0,0 @@
0.3.0
-22
View File
@@ -1,22 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Backend (Docker)",
"type": "go",
"request": "attach",
"mode": "remote",
"substitutePath": [
{
"from": "${workspaceFolder}",
"to": "/app"
}
],
"port": 2345,
"host": "127.0.0.1",
"showLog": true,
"trace": "log",
"logOutput": "rpc"
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"githubPullRequests.ignoredPullRequestBranches": [
"main"
]
}
-40
View File
@@ -1,40 +0,0 @@
{
"python-envs.pythonProjects": [
{
"path": "",
"envManager": "ms-python.python:venv",
"packageManager": "ms-python.python:pip"
}
]
,
"gopls": {
"buildFlags": ["-tags=ignore", "-mod=mod"],
"env": {
"GOWORK": "off",
"GOFLAGS": "-mod=mod",
"GOTOOLCHAIN": "none"
},
"directoryFilters": [
"-**/pkg/mod/**",
"-**/go/pkg/mod/**",
"-**/root/go/pkg/mod/**",
"-**/golang.org/toolchain@**"
]
},
"go.buildFlags": ["-tags=ignore", "-mod=mod"],
"go.toolsEnvVars": {
"GOWORK": "off",
"GOFLAGS": "-mod=mod",
"GOTOOLCHAIN": "none"
},
"files.watcherExclude": {
"**/pkg/mod/**": true,
"**/go/pkg/mod/**": true,
"**/root/go/pkg/mod/**": true
},
"search.exclude": {
"**/pkg/mod/**": true,
"**/go/pkg/mod/**": true,
"**/root/go/pkg/mod/**": true
}
}
-137
View File
@@ -1,137 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Git Remove Cached",
"type": "shell",
"command": "git rm -r --cached .",
"group": "test"
},
{
"label": "Run Pre-commit (All Files)",
"type": "shell",
"command": "${workspaceFolder}/.venv/bin/pre-commit run --all-files",
"group": "test"
},
// === MANUAL LINT/SCAN TASKS ===
// These are the slow hooks removed from automatic pre-commit
{
"label": "Lint: GolangCI-Lint",
"type": "shell",
"command": "cd backend && docker run --rm -v $(pwd):/app:ro -w /app golangci/golangci-lint:latest golangci-lint run -v",
"group": "test",
"problemMatcher": ["$go"],
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "Lint: Go Race Detector",
"type": "shell",
"command": "cd backend && go test -race ./...",
"group": "test",
"problemMatcher": ["$go"],
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "Lint: Hadolint (Dockerfile)",
"type": "shell",
"command": "docker run --rm -i hadolint/hadolint < Dockerfile",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "new"
}
},
{
"label": "Lint: Run All Manual Checks",
"type": "shell",
"command": "${workspaceFolder}/.venv/bin/pre-commit run --all-files --hook-stage manual",
"group": "test",
"problemMatcher": [],
"presentation": {
"reveal": "always",
"panel": "new"
}
},
// === BUILD & RUN TASKS ===
{
"label": "Build & Run Local Docker",
"type": "shell",
"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 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"
},
{
"label": "Run Trivy Scan (Local)",
"type": "shell",
"command": "docker",
"args": [
"run",
"--rm",
"-v",
"/var/run/docker.sock:/var/run/docker.sock",
"-v",
"${userHome}/.cache/trivy:/root/.cache/trivy",
"-v",
"${workspaceFolder}/.trivy_logs:/logs",
"aquasec/trivy:latest",
"image",
"--severity",
"CRITICAL,HIGH",
"--output",
"/logs/trivy-report.txt",
"charon:local"
],
"isBackground": false,
"group": "test"
},
{
"label": "Run CodeQL Scan (Local)",
"type": "shell",
"command": "${workspaceFolder}/tools/codeql_scan.sh",
"group": "test"
},
{
"label": "Run Security Scan (govulncheck)",
"type": "shell",
"command": "${workspaceFolder}/scripts/security-scan.sh",
"group": "test",
"problemMatcher": []
},
{
"label": "Docker: Restart Local (No Rebuild)",
"type": "shell",
"command": "docker compose -f docker-compose.local.yml down && docker compose -f docker-compose.local.yml up -d",
"group": "test",
"isBackground": false,
"problemMatcher": []
},
{
"label": "Docker: Stop Local",
"type": "shell",
"command": "docker compose -f docker-compose.local.yml down",
"group": "test",
"isBackground": false,
"problemMatcher": []
},
{
"label": "Docker: Start Local (Already Built)",
"type": "shell",
"command": "docker compose -f docker-compose.local.yml up -d",
"group": "test",
"isBackground": false,
"problemMatcher": []
}
]
}
-387
View File
@@ -1,387 +0,0 @@
# Contributing to Charon
Thank you for your interest in contributing to CaddyProxyManager+! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Workflow](#development-workflow)
- [Coding Standards](#coding-standards)
- [Testing Guidelines](#testing-guidelines)
- [Pull Request Process](#pull-request-process)
- [Issue Guidelines](#issue-guidelines)
- [Documentation](#documentation)
## Code of Conduct
This project follows a Code of Conduct that all contributors are expected to adhere to:
- Be respectful and inclusive
- Welcome newcomers and help them get started
- Focus on what's best for the community
- Show empathy towards other community members
## Getting Started
-### Prerequisites
- **Go 1.24+** for backend development
- **Node.js 20+** and npm for frontend development
- Git for version control
- A GitHub account
### Fork and Clone
1. Fork the repository on GitHub
2. Clone your fork locally:
```bash
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/charon.git
```
### Set Up Development Environment
**Backend:**
```bash
cd backend
go mod download
go run ./cmd/seed/main.go # Seed test data
go run ./cmd/api/main.go # Start backend
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # Start frontend dev server
```
## Development Workflow
### Branching Strategy
- **main** - Production-ready code
- **development** - Main development branch (default)
- **feature/** - Feature branches (e.g., `feature/add-ssl-support`)
- **bugfix/** - Bug fix branches (e.g., `bugfix/fix-import-crash`)
- **hotfix/** - Urgent production fixes
### Creating a Feature Branch
Always branch from `development`:
```bash
git checkout development
git pull upstream development
git checkout -b feature/your-feature-name
```
### Commit Message Guidelines
Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <subject>
<body>
<footer>
```
**Types:**
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation only
- `style`: Code style changes (formatting, etc.)
- `refactor`: Code refactoring
- `test`: Adding or updating tests
- `chore`: Maintenance tasks
**Examples:**
```
feat(proxy-hosts): add SSL certificate upload
- Implement certificate upload endpoint
- Add UI for certificate management
- Update database schema
Closes #123
```
```
fix(import): resolve conflict detection bug
When importing Caddyfiles with multiple domains, conflicts
were not being detected properly.
Fixes #456
```
### Keeping Your Fork Updated
```bash
git checkout development
git fetch upstream
git merge upstream/development
git push origin development
```
## Coding Standards
### Go Backend
- Follow standard Go formatting (`gofmt`)
- Use meaningful variable and function names
- Write godoc comments for exported functions
- Keep functions small and focused
- Handle errors explicitly
**Example:**
```go
// GetProxyHost retrieves a proxy host by UUID.
// Returns an error if the host is not found.
func GetProxyHost(uuid string) (*models.ProxyHost, error) {
var host models.ProxyHost
if err := db.First(&host, "uuid = ?", uuid).Error; err != nil {
return nil, fmt.Errorf("proxy host not found: %w", err)
}
return &host, nil
}
```
### TypeScript Frontend
- Use TypeScript for type safety
- Follow React best practices and hooks patterns
- Use functional components
- Destructure props at function signature
- Extract reusable logic into custom hooks
**Example:**
```typescript
interface ProxyHostFormProps {
host?: ProxyHost
onSubmit: (data: ProxyHostData) => Promise<void>
onCancel: () => void
}
export function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
const [domain, setDomain] = useState(host?.domain ?? '')
// ... component logic
}
```
### CSS/Styling
- Use TailwindCSS utility classes
- Follow the dark theme color palette
- Keep custom CSS minimal
- Use semantic color names from the theme
## Testing Guidelines
### Backend Tests
Write tests for all new functionality:
```go
func TestGetProxyHost(t *testing.T) {
// Setup
db := setupTestDB(t)
host := createTestHost(db)
// Execute
result, err := GetProxyHost(host.UUID)
// Assert
assert.NoError(t, err)
assert.Equal(t, host.Domain, result.Domain)
}
```
**Run tests:**
```bash
go test ./... -v
go test -cover ./...
```
### Frontend Tests
Write component and hook tests using Vitest and React Testing Library:
```typescript
describe('ProxyHostForm', () => {
it('renders create form with empty fields', async () => {
render(
<ProxyHostForm onSubmit={vi.fn()} onCancel={vi.fn()} />
)
await waitFor(() => {
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
})
})
})
```
**Run tests:**
```bash
npm test # Watch mode
npm run test:coverage # Coverage report
```
### Test Coverage
- Aim for 80%+ code coverage
- All new features must include tests
- Bug fixes should include regression tests
## Pull Request Process
### Before Submitting
1. **Ensure tests pass:**
```bash
# Backend
go test ./...
# Frontend
npm test -- --run
```
2. **Check code quality:**
```bash
# Go formatting
go fmt ./...
# Frontend linting
npm run lint
```
3. **Update documentation** if needed
4. **Add tests** for new functionality
5. **Rebase on latest development** branch
### Submitting a Pull Request
1. Push your branch to your fork:
```bash
git push origin feature/your-feature-name
```
2. Open a Pull Request on GitHub
3. Fill out the PR template completely
4. Link related issues using "Closes #123" or "Fixes #456"
5. Request review from maintainers
### PR Template
```markdown
## Description
Brief description of changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] All tests passing
## Screenshots (if applicable)
Add screenshots of UI changes
## Checklist
- [ ] Code follows style guidelines
- [ ] Self-review performed
- [ ] Comments added for complex code
- [ ] Documentation updated
- [ ] No new warnings generated
```
### Review Process
- Maintainers will review within 2-3 business days
- Address review feedback promptly
- Keep discussions focused and professional
- Be open to suggestions and alternative approaches
## Issue Guidelines
### Reporting Bugs
Use the bug report template and include:
- Clear, descriptive title
- Steps to reproduce
- Expected vs actual behavior
- Environment details (OS, browser, Go version, etc.)
- Screenshots or error logs
- Potential solutions (if known)
### Feature Requests
Use the feature request template and include:
- Clear description of the feature
- Use case and motivation
- Potential implementation approach
- Mockups or examples (if applicable)
### Issue Labels
- `bug` - Something isn't working
- `enhancement` - New feature or request
- `documentation` - Documentation improvements
- `good first issue` - Good for newcomers
- `help wanted` - Extra attention needed
- `priority: high` - Urgent issue
- `wontfix` - Will not be fixed
## Documentation
### Code Documentation
- Add docstrings to all exported functions
- Include examples in complex functions
- Document return types and error conditions
- Keep comments up-to-date with code changes
### Project Documentation
When adding features, update:
- `README.md` - User-facing information
- `docs/api.md` - API changes
- `docs/import-guide.md` - Import feature updates
- `docs/database-schema.md` - Schema changes
## Recognition
Contributors will be recognized in:
- CONTRIBUTORS.md file
- Release notes for significant contributions
- GitHub contributors page
## 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
## License
By contributing, you agree that your contributions will be licensed under the project's MIT License.
---
Thank you for contributing to CaddyProxyManager+! 🎉
-10
View File
@@ -1,10 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"codeQL.createQuery.qlPackLocation": "/projects/Charon"
}
}
-203
View File
@@ -1,203 +0,0 @@
# Docker Deployment Guide
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/charon.git
cd charon
# Start the stack
docker-compose up -d
# Access the UI
open http://localhost:8080
```
## Architecture
Charon runs as a **single container** that includes:
1. **Caddy Server**: The reverse proxy engine (ports 80/443).
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 (charon / cpmp) │
│ │
│ ┌──────────┐ API ┌──────────────┐ │
│ │ Caddy │◄──:2019──┤ Charon App │ │
│ │ (Proxy) │ │ (Manager) │ │
│ └────┬─────┘ └──────┬───────┘ │
│ │ │ │
└───────┼───────────────────────┼──────────┘
│ :80, :443 │ :8080
▼ ▼
Internet Web UI
```
## Configuration
### Volumes
Persist your data by mounting these volumes:
| Host Path | Container Path | Description |
|-----------|----------------|-------------|
| `./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. |
### Environment Variables
Configure the application via `docker-compose.yml`:
| Variable | Default | Description |
|----------|---------|-------------|
| `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/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/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 "charon".
2. **Manual Install**:
* Click **Add Container**.
* **Name**: Charon
* **Repository**: `ghcr.io/wikid82/charon:latest`
* **Network Type**: Bridge
* **WebUI**: `http://[IP]:[PORT:8080]`
* **Port mappings**:
* Container Port: `80` -> Host Port: `80`
* Container Port: `443` -> Host Port: `443`
* Container Port: `8080` -> Host Port: `8080`
* **Paths**:
* `/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
### App can't reach Caddy
**Symptom**: "Caddy unreachable" errors in logs
**Solution**: Since both run in the same container, this usually means Caddy failed to start. Check logs:
```bash
docker-compose logs app
```
### Certificates not working
**Symptom**: HTTP works but HTTPS fails
**Check**:
1. Port 80/443 are accessible from the internet
2. DNS points to your server
3. Caddy logs: `docker-compose logs app | grep -i acme`
### Config changes not applied
**Symptom**: Changes in UI don't affect routing
**Debug**:
```bash
# View current Caddy config
curl http://localhost:2019/config/ | jq
# Check Charon logs
docker-compose logs app
# Manual config reload
curl -X POST http://localhost:8080/api/v1/caddy/reload
```
## Updating
Pull the latest images and restart:
```bash
docker-compose pull
docker-compose up -d
```
For specific versions:
```bash
# Edit docker-compose.yml to pin version
image: ghcr.io/wikid82/charon:v1.0.0
docker-compose up -d
```
## Building from Source
```bash
# Build multi-arch images
docker buildx build --platform linux/amd64,linux/arm64 -t charon:local .
# Or use Make
make docker-build
```
## Security Considerations
1. **Caddy admin API**: Keep port 2019 internal (not exposed in production compose)
2. **Management UI**: Add authentication (Issue #7) before exposing to internet
3. **Certificates**: Caddy stores private keys in `caddy_data` - protect this volume
4. **Database**: SQLite file contains all config - backup regularly
## Integration with Existing Caddy
If you already have Caddy running, you can point Charon to it:
```yaml
environment:
- CPM_CADDY_ADMIN_API=http://your-caddy-host:2019
```
**Warning**: Charon will replace Caddy's entire configuration. Backup first!
## Performance Tuning
For high-traffic deployments:
```yaml
# docker-compose.yml
services:
app:
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
```
## Next Steps
- Configure your first proxy host via UI
- Enable automatic HTTPS (happens automatically)
- Add authentication (Issue #7)
- Integrate CrowdSec (Issue #15)
-202
View File
@@ -1,202 +0,0 @@
# Multi-stage Dockerfile for Charon with integrated Caddy
# Single container deployment for simplified home user setup
# Build arguments for versioning
ARG VERSION=dev
ARG BUILD_DATE
ARG VCS_REF
# Allow pinning Caddy version - Renovate will update this
# Build the most recent Caddy 2.x release (keeps major pinned under v3).
# Setting this to '2' tells xcaddy to resolve the latest v2.x tag so we
# avoid accidentally pulling a v3 major release. Renovate can still update
# this ARG to a specific v2.x tag when desired.
## Try to build the requested Caddy v2.x tag (Renovate can update this ARG).
## If the requested tag isn't available, fall back to a known-good v2.10.2 build.
ARG CADDY_VERSION=2.10.2
## When an official caddy image tag isn't available on the host, use a
## plain Alpine base image and overwrite its caddy binary with our
## xcaddy-built binary in the later COPY step. This avoids relying on
## upstream caddy image tags while still shipping a pinned caddy binary.
ARG CADDY_IMAGE=alpine:3.18
# ---- Cross-Compilation Helpers ----
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.8.0 AS xx
# ---- Frontend Builder ----
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
COPY frontend/package*.json ./
# Set environment to bypass native binary requirement for cross-arch builds
ENV npm_config_rollup_skip_nodejs_native=1 \
ROLLUP_SKIP_NODEJS_NATIVE=1
RUN npm ci
# Copy frontend source and build
COPY frontend/ ./
RUN --mount=type=cache,target=/app/frontend/node_modules/.cache \
npm run build
# ---- Backend Builder ----
FROM --platform=$BUILDPLATFORM golang:alpine AS backend-builder
# Copy xx helpers for cross-compilation
COPY --from=xx / /
WORKDIR /app/backend
# Install build dependencies
# xx-apk installs packages for the TARGET architecture
ARG TARGETPLATFORM
# hadolint ignore=DL3018
RUN apk add --no-cache clang lld
# hadolint ignore=DL3018,DL3059
RUN xx-apk add --no-cache gcc musl-dev sqlite-dev
# Install Delve (cross-compile for target)
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling.
# We find it and move it to /go/bin/dlv so it's in a consistent location for the next stage.
# hadolint ignore=DL3059,DL4006
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@latest && \
DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \
if [ -n "$DLV_PATH" ] && [ "$DLV_PATH" != "/go/bin/dlv" ]; then \
mv "$DLV_PATH" /go/bin/dlv; \
fi && \
xx-verify /go/bin/dlv
# Copy Go module files
COPY backend/go.mod backend/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
# Copy backend source
COPY backend/ ./
# Build arguments passed from main build context
ARG VERSION=dev
ARG VCS_REF=unknown
ARG BUILD_DATE=unknown
# Build the Go binary with version information injected via ldflags
# xx-go handles CGO and cross-compilation flags automatically
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/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
# This fixes vulnerabilities found in the pre-built Caddy images (e.g. CVE-2025-59530, stdlib issues)
FROM --platform=$BUILDPLATFORM golang:alpine AS caddy-builder
ARG TARGETOS
ARG TARGETARCH
ARG CADDY_VERSION
# hadolint ignore=DL3018
RUN apk add --no-cache git
# hadolint ignore=DL3062
RUN --mount=type=cache,target=/go/pkg/mod \
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
# Pre-fetch/override vulnerable module versions in the module cache so xcaddy
# will pick them up during the build. These `go get` calls attempt to pin
# fixed versions of dependencies known to cause Trivy findings (expr, quic-go).
RUN --mount=type=cache,target=/go/pkg/mod \
go get github.com/expr-lang/expr@v1.17.0 github.com/quic-go/quic-go@v0.54.1 || true
# Build Caddy for the target architecture with security plugins.
# Try the requested v${CADDY_VERSION} tag first; if it fails (unknown tag),
# fall back to a known-good v2.10.2 build to keep the build resilient.
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
sh -c "GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_VERSION} \
--with github.com/greenpau/caddy-security \
--with github.com/corazawaf/coraza-caddy/v2 \
--with github.com/hslatman/caddy-crowdsec-bouncer \
--with github.com/zhangjiayin/caddy-geoip2 \
--output /usr/bin/caddy || \
(echo 'Requested Caddy tag v${CADDY_VERSION} failed; falling back to v2.10.2' && \
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v2.10.2 \
--with github.com/greenpau/caddy-security \
--with github.com/corazawaf/coraza-caddy/v2 \
--with github.com/hslatman/caddy-crowdsec-bouncer \
--with github.com/zhangjiayin/caddy-geoip2 --output /usr/bin/caddy)"
# ---- Final Runtime with Caddy ----
FROM ${CADDY_IMAGE}
WORKDIR /app
# Install runtime dependencies for Charon (no bash needed)
# hadolint ignore=DL3018
RUN apk --no-cache add ca-certificates sqlite-libs tzdata curl \
&& apk --no-cache upgrade
# Download MaxMind GeoLite2 Country database
# Note: In production, users should provide their own MaxMind license key
# This uses the publicly available GeoLite2 database
RUN mkdir -p /app/data/geoip && \
curl -L "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb" \
-o /app/data/geoip/GeoLite2-Country.mmdb
# Copy Caddy binary from caddy-builder (overwriting the one from base image)
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
# Copy Go binary from backend builder
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
# Copy frontend build from frontend builder
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
# Copy startup script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Set default environment variables
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 \
CPM_CADDY_ADMIN_API=http://localhost:2019 \
CPM_CADDY_CONFIG_DIR=/app/data/caddy \
CPM_GEOIP_DB_PATH=/app/data/geoip/GeoLite2-Country.mmdb
# Create necessary directories
RUN mkdir -p /app/data /app/data/caddy /config
# Re-declare build args for LABEL usage
ARG VERSION=dev
ARG BUILD_DATE
ARG VCS_REF
# OCI image labels for version metadata
LABEL org.opencontainers.image.title="Charon (CPMP legacy)" \
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/charon" \
org.opencontainers.image.url="https://github.com/Wikid82/charon" \
org.opencontainers.image.vendor="charon" \
org.opencontainers.image.licenses="MIT"
# Expose ports
EXPOSE 80 443 443/udp 8080 2019
# Use custom entrypoint to start both Caddy and Charon
ENTRYPOINT ["/docker-entrypoint.sh"]
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Wikid82
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-160
View File
@@ -1,160 +0,0 @@
.PHONY: help install test build run clean docker-build docker-run release
# Default target
help:
@echo "Charon Build System"
@echo ""
@echo "Available targets:"
@echo " install - Install all dependencies (backend + frontend)"
@echo " test - Run all tests (backend + frontend)"
@echo " build - Build backend and frontend"
@echo " run - Run backend in development mode"
@echo " clean - Clean build artifacts"
@echo " docker-build - Build Docker image"
@echo " docker-build-versioned - Build Docker image with version from .version file"
@echo " docker-run - Run Docker container"
@echo " docker-dev - Run Docker in development mode"
@echo " release - Create a new semantic version release (interactive)"
@echo " dev - Run both backend and frontend in dev mode (requires tmux)"
@echo ""
@echo "Security targets:"
@echo " security-scan - Quick security scan (govulncheck on Go deps)"
@echo " security-scan-full - Full container scan with Trivy"
@echo " security-scan-deps - Check for outdated Go dependencies"
# Install all dependencies
install:
@echo "Installing backend dependencies..."
cd backend && go mod download
@echo "Installing frontend dependencies..."
cd frontend && npm install
# Run all tests
test:
@echo "Running backend tests..."
cd backend && go test -v ./...
@echo "Running frontend lint..."
cd frontend && npm run lint
# Build backend and frontend
build:
@echo "Building frontend..."
cd frontend && npm run 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
# Run frontend in development mode
run-frontend:
cd frontend && npm run dev
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
rm -rf backend/bin backend/data
rm -rf frontend/dist frontend/node_modules
go clean -cache
# Build Docker image
docker-build:
docker-compose build
# Build Docker image with version
docker-build-versioned:
@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 charon:$$VERSION \
-t charon:latest \
.
# Run Docker containers (production)
docker-run:
docker-compose up -d
# Run Docker containers (development)
docker-dev:
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
# Stop Docker containers
docker-stop:
docker-compose down
# View Docker logs
docker-logs:
docker-compose logs -f
# 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 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:
@./scripts/release.sh
# Security scanning targets
security-scan:
@echo "Running security scan (govulncheck)..."
@./scripts/security-scan.sh
security-scan-full:
@echo "Building local Docker image for security scan..."
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 \
charon:local
security-scan-deps:
@echo "Scanning Go dependencies..."
cd backend && go list -m -json all | docker run --rm -i aquasec/trivy:latest sbom --format json - 2>/dev/null || true
@echo "Checking for Go module updates..."
cd backend && go list -m -u all | grep -E '\[.*\]' || echo "All modules up to date"
# Quality Assurance targets
lint-backend:
@echo "Running golangci-lint..."
cd backend && docker run --rm -v $(PWD)/backend:/app -w /app golangci/golangci-lint:latest golangci-lint run -v
lint-docker:
@echo "Running Hadolint..."
docker run --rm -i hadolint/hadolint < Dockerfile
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 ./...
integration-test:
@echo "Running integration tests..."
@./scripts/integration-test.sh
-122
View File
@@ -1,122 +0,0 @@
<p align="center">
<img src="frontend/public/banner.png" alt="Charon" width="600">
</p>
<h1 align="center">Charon</h1>
<p align="center"> <strong>The Gateway to Effortless Connectivity.</strong>
Charon bridges the gap between the complex internet and your private services. Enjoy a simplified, visual management experience built specifically for the home server enthusiast. No code required—just safe passage. </p>
<h2 align="center">Cerberus</h2>
<p align="center"> <strong>The Guardian at the Gate.</strong>
Ensure nothing passes without permission. Cerberus is a robust security suite featuring the Coraza WAF, deep CrowdSec integration, and granular rate-limiting. Always watching, always protecting. </p>
<br><br>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
<a href="https://github.com/Wikid82/charon/releases"><img src="https://img.shields.io/github/v/release/Wikid82/charon?include_prereleases" alt="Release"></a>
<a href="https://github.com/Wikid82/charon/actions"><img src="https://img.shields.io/github/actions/workflow/status/Wikid82/charon/docker-publish.yml" alt="Build Status"></a>
</p>
---
## ✨ Top Features
| Feature | Description |
|---------|-------------|
| 🔐 **Automatic HTTPS** | Free SSL certificates from Let's Encrypt, auto-renewed |
| 🛡️ **Built-in Security** | CrowdSec integration, geo-blocking, IP access lists (optional, powered by Cerberus) |
| ⚡ **Zero Downtime** | Hot-reload configuration without restarts |
| 🐳 **Docker Discovery** | Auto-detect containers on local and remote Docker hosts |
| 📊 **Uptime Monitoring** | Know when your services go down with smart notifications |
| 🔍 **Health Checks** | Test connections before saving |
| 📥 **Easy Import** | Bring your existing Caddy configs with one click |
| 💾 **Backup & Restore** | Never lose your settings, export anytime |
| 🌐 **WebSocket Support** | Perfect for real-time apps and chat services |
| 🎨 **Beautiful Dark UI** | Modern interface that's easy on the eyes, works on any device |
**[See all features →](https://wikid82.github.io/charon/features)**
---
## 🚀 Quick Start
```bash
services:
charon:
image: ghcr.io/wikid82/charon:latest
container_name: charon
restart: unless-stopped
ports:
- "80:80" # HTTP (Caddy proxy)
- "443:443" # HTTPS (Caddy proxy)
- "443:443/udp" # HTTP/3 (Caddy proxy)
- "8080:8080" # Management UI (Charon)
environment:
- CHARON_ENV=production # New env var prefix (CHARON_). CPM_ values still supported.
- TZ=UTC # Set timezone (e.g., America/New_York)
- 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_CADDY_BINARY=caddy
- CHARON_IMPORT_CADDYFILE=/import/Caddyfile
- CHARON_IMPORT_DIR=/app/data/imports
# Security Services (Optional)
#- CERBERUS_SECURITY_CROWDSEC_MODE=disabled # disabled, local, external
#- CERBERUS_SECURITY_CROWDSEC_API_URL= # Required if mode is external
#- CERBERUS_SECURITY_CROWDSEC_API_KEY= # Required if mode is external
#- CERBERUS_SECURITY_WAF_MODE=disabled # disabled, enabled
#- CERBERUS_SECURITY_RATELIMIT_ENABLED=false
#- CERBERUS_SECURITY_ACL_ENABLED=false
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- <path_to_charon_data>:/app/data
- <path_to_caddy_data>:/data
- <path_to_caddy_config>:/config
- /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery
# Mount your existing Caddyfile for automatic import (optional)
# - ./my-existing-Caddyfile:/import/Caddyfile:ro
# - ./sites:/import/sites:ro # If your Caddyfile imports other files
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
Open **http://localhost:8080** — that's it! 🎉
**[Full documentation →](https://wikid82.github.io/charon/)**
---
## 💬 Community
- 🐛 **Found a bug?** [Open an issue](https://github.com/Wikid82/charon/issues)
- 💡 **Have an idea?** [Start a discussion](https://github.com/Wikid82/charon/discussions)
- 📋 **Roadmap** [View the project board](https://github.com/users/Wikid82/projects/7)
## 🤝 Contributing
We welcome contributions! See our [Contributing Guide](CONTRIBUTING.md) to get started.
---
<p align="center">
<a href="LICENSE"><strong>MIT License</strong></a> ·
<a href="https://wikid82.github.io/charon/"><strong>Documentation</strong></a> ·
<a href="https://github.com/Wikid82/charon/releases"><strong>Releases</strong></a>
</p>
<p align="center">
<em>Built with ❤️ by <a href="https://github.com/Wikid82">@Wikid82</a></em><br>
<sub>Powered by <a href="https://caddyserver.com/">Caddy Server</a> · Inspired by <a href="https://nginxproxymanager.com/">Nginx Proxy Manager</a> & <a href="https://pangolin.net/">Pangolin</a></sub>
</p>
-148
View File
@@ -1,148 +0,0 @@
# Versioning Guide
## Semantic Versioning
Charon follows [Semantic Versioning 2.0.0](https://semver.org/):
- **MAJOR.MINOR.PATCH** (e.g., `1.2.3`)
- **MAJOR**: Incompatible API changes
- **MINOR**: New functionality (backward compatible)
- **PATCH**: Bug fixes (backward compatible)
### Pre-release Identifiers
- `alpha`: Early development, unstable
- `beta`: Feature complete, testing phase
- `rc` (release candidate): Final testing before release
Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2`
## Creating a Release
### Automated Release Process
1. **Update version** in `.version` file:
```bash
echo "1.0.0" > .version
```
2. **Commit version bump**:
```bash
git add .version
git commit -m "chore: bump version to 1.0.0"
```
3. **Create and push tag**:
```bash
git tag -a v1.0.0 -m "Release v1.0.0"
git push origin v1.0.0
```
4. **GitHub Actions automatically**:
- Creates GitHub Release with changelog
- Builds multi-arch Docker images (amd64, arm64)
- Publishes to GitHub Container Registry with tags:
- `v1.0.0` (exact version)
- `1.0` (minor version)
- `1` (major version)
- `latest` (for non-prerelease on main branch)
## Container Image Tags
### Available Tags
- **`latest`**: Latest stable release (main branch)
- **`development`**: Latest development build (development branch)
- **`v1.2.3`**: Specific version tag
- **`1.2`**: Latest patch for minor version
- **`1`**: Latest minor for major version
- **`main-<sha>`**: Commit-specific build from main
- **`development-<sha>`**: Commit-specific build from development
### Usage Examples
```bash
# Use latest stable release
docker pull ghcr.io/wikid82/charon:latest
# Use specific version
docker pull ghcr.io/wikid82/charon:v1.0.0
# Use development builds
docker pull ghcr.io/wikid82/charon:development
# Use specific commit
docker pull ghcr.io/wikid82/charon:main-abc123
```
## Version Information
### Runtime Version Endpoint
```bash
curl http://localhost:8080/api/v1/health
```
Response includes:
```json
{
"status": "ok",
"service": "charon",
"version": "1.0.0",
"git_commit": "abc1234567890def",
"build_date": "2025-11-17T12:34:56Z"
}
```
### Container Image Labels
View version metadata:
```bash
docker inspect ghcr.io/wikid82/charon:latest \
--format='{{json .Config.Labels}}' | jq
```
Returns OCI-compliant labels:
- `org.opencontainers.image.version`
- `org.opencontainers.image.created`
- `org.opencontainers.image.revision`
- `org.opencontainers.image.source`
## Development Builds
Local builds default to `version=dev`:
```bash
docker build -t charon:dev .
```
Build with custom version:
```bash
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 charon:1.2.3 .
```
## Changelog Generation
The release workflow automatically generates changelogs from commit messages. Use conventional commit format:
- `feat:` New features
- `fix:` Bug fixes
- `docs:` Documentation changes
- `chore:` Maintenance tasks
- `refactor:` Code refactoring
- `test:` Test updates
- `ci:` CI/CD changes
Example:
```bash
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.
-15
View File
@@ -1,15 +0,0 @@
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
CPM_CADDY_ADMIN_API=http://localhost:2019
CPM_CADDY_CONFIG_DIR=./data/caddy
-73
View File
@@ -1,73 +0,0 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
enable:
- bodyclose
- gocritic
- gosec
- govet
- ineffassign
- staticcheck
- unused
- errcheck
settings:
gocritic:
enabled-tags:
- diagnostic
- performance
disabled-checks:
- whyNoLint
- wrapperFunc
- hugeParam
- rangeValCopy
- ifElseChain
- appendCombine
- appendAssign
- commentedOutCode
- sprintfQuotedString
govet:
enable:
- shadow
errcheck:
exclude-functions:
# Ignore deferred close errors - these are intentional
- (io.Closer).Close
- (*os.File).Close
- (net/http.ResponseWriter).Write
- (*encoding/json.Encoder).Encode
- (*encoding/json.Decoder).Decode
# Test utilities
- os.Setenv
- os.Unsetenv
- os.RemoveAll
- os.MkdirAll
- os.WriteFile
- os.Remove
- (*gorm.io/gorm.DB).AutoMigrate
exclusions:
generated: lax
presets:
- comments
rules:
# Exclude some linters from running on tests
- path: _test\.go
linters:
- errcheck
- gosec
- govet
- ineffassign
- staticcheck
# Exclude gosec file permission warnings - 0644/0755 are intentional for config/data dirs
- linters:
- gosec
text: "G301:|G304:|G306:|G104:|G110:|G305:|G602:"
# Exclude shadow warnings in specific patterns
- linters:
- govet
text: "shadows declaration"
-19
View File
@@ -1,19 +0,0 @@
# Backend Service
This folder contains the Go API for CaddyProxyManager+.
## Prerequisites
- Go 1.24+
## Getting started
```bash
cp .env.example .env # optional
cd backend
go run ./cmd/api
```
## Tests
```bash
cd backend
go test ./...
```
BIN
View File
Binary file not shown.
-1658
View File
File diff suppressed because it is too large Load Diff
-122
View File
@@ -1,122 +0,0 @@
package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"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"
)
func main() {
// Setup logging with rotation
logDir := "/app/data/logs"
if err := os.MkdirAll(logDir, 0755); err != nil {
// Fallback to local directory if /app/data fails (e.g. local dev)
logDir = "data/logs"
_ = os.MkdirAll(logDir, 0755)
}
logFile := filepath.Join(logDir, "charon.log")
rotator := &lumberjack.Logger{
Filename: logFile,
MaxSize: 10, // megabytes
MaxBackups: 3,
MaxAge: 28, // days
Compress: true,
}
// Ensure legacy cpmp.log exists as symlink for compatibility (cpmp is a legacy name for Charon)
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)
gin.DefaultWriter = mw
// Handle CLI commands
if len(os.Args) > 1 && os.Args[1] == "reset-password" {
if len(os.Args) != 4 {
log.Fatalf("Usage: %s reset-password <email> <new-password>", os.Args[0])
}
email := os.Args[2]
newPassword := os.Args[3]
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
db, err := database.Connect(cfg.DatabasePath)
if err != nil {
log.Fatalf("connect database: %v", err)
}
var user models.User
if err := db.Where("email = ?", email).First(&user).Error; err != nil {
log.Fatalf("user not found: %v", err)
}
if err := user.SetPassword(newPassword); err != nil {
log.Fatalf("failed to hash password: %v", err)
}
// Unlock account if locked
user.LockedUntil = nil
user.FailedLoginAttempts = 0
if err := db.Save(&user).Error; err != nil {
log.Fatalf("failed to save user: %v", err)
}
log.Printf("Password updated successfully for user %s", email)
return
}
log.Printf("starting %s backend on version %s", version.Name, version.Full())
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
db, err := database.Connect(cfg.DatabasePath)
if err != nil {
log.Fatalf("connect database: %v", err)
}
router := server.NewRouter(cfg.FrontendDir)
// Pass config to routes for auth service and certificate service
if err := routes.Register(router, db, cfg); err != nil {
log.Fatalf("register routes: %v", err)
}
// Register import handler with config dependencies
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
// Check for mounted Caddyfile on startup
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {
log.Printf("WARNING: failed to process mounted Caddyfile: %v", err)
}
addr := fmt.Sprintf(":%s", cfg.HTTPPort)
log.Printf("starting %s backend on %s", version.Name, addr)
if err := router.Run(addr); err != nil {
log.Fatalf("server error: %v", err)
}
}
-248
View File
@@ -1,248 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"github.com/google/uuid"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
func main() {
// Connect to database
db, err := gorm.Open(sqlite.Open("./data/charon.db"), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
// Auto migrate
if err := db.AutoMigrate(
&models.User{},
&models.ProxyHost{},
&models.CaddyConfig{},
&models.RemoteServer{},
&models.SSLCertificate{},
&models.AccessList{},
&models.Setting{},
&models.ImportSession{},
); err != nil {
log.Fatal("Failed to migrate database:", err)
}
fmt.Println("✓ Database migrated successfully")
// Seed Remote Servers
remoteServers := []models.RemoteServer{
{
UUID: uuid.NewString(),
Name: "Local Docker Registry",
Provider: "docker",
Host: "localhost",
Port: 5000,
Scheme: "http",
Description: "Local Docker container registry",
Enabled: true,
Reachable: false,
},
{
UUID: uuid.NewString(),
Name: "Development API Server",
Provider: "generic",
Host: "192.168.1.100",
Port: 8080,
Scheme: "http",
Description: "Main development API backend",
Enabled: true,
Reachable: false,
},
{
UUID: uuid.NewString(),
Name: "Staging Web App",
Provider: "vm",
Host: "staging.internal",
Port: 3000,
Scheme: "http",
Description: "Staging environment web application",
Enabled: true,
Reachable: false,
},
{
UUID: uuid.NewString(),
Name: "Database Admin",
Provider: "docker",
Host: "localhost",
Port: 8081,
Scheme: "http",
Description: "PhpMyAdmin or similar DB management tool",
Enabled: false,
Reachable: false,
},
}
for _, server := range remoteServers {
result := db.Where("host = ? AND port = ?", server.Host, server.Port).FirstOrCreate(&server)
if result.Error != nil {
log.Printf("Failed to seed remote server %s: %v", server.Name, result.Error)
} else if result.RowsAffected > 0 {
fmt.Printf("✓ Created remote server: %s (%s:%d)\n", server.Name, server.Host, server.Port)
} else {
fmt.Printf(" Remote server already exists: %s\n", server.Name)
}
}
// Seed Proxy Hosts
proxyHosts := []models.ProxyHost{
{
UUID: uuid.NewString(),
Name: "Development App",
DomainNames: "app.local.dev",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 3000,
SSLForced: false,
WebsocketSupport: true,
HSTSEnabled: false,
BlockExploits: true,
Enabled: true,
},
{
UUID: uuid.NewString(),
Name: "API Server",
DomainNames: "api.local.dev",
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
SSLForced: false,
WebsocketSupport: false,
HSTSEnabled: false,
BlockExploits: true,
Enabled: true,
},
{
UUID: uuid.NewString(),
Name: "Docker Registry",
DomainNames: "docker.local.dev",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 5000,
SSLForced: false,
WebsocketSupport: false,
HSTSEnabled: false,
BlockExploits: true,
Enabled: false,
},
}
for _, host := range proxyHosts {
result := db.Where("domain_names = ?", host.DomainNames).FirstOrCreate(&host)
if result.Error != nil {
log.Printf("Failed to seed proxy host %s: %v", host.DomainNames, result.Error)
} else if result.RowsAffected > 0 {
fmt.Printf("✓ Created proxy host: %s -> %s://%s:%d\n",
host.DomainNames, host.ForwardScheme, host.ForwardHost, host.ForwardPort)
} else {
fmt.Printf(" Proxy host already exists: %s\n", host.DomainNames)
}
}
// Seed Settings
settings := []models.Setting{
{
Key: "app_name",
Value: "Charon",
Type: "string",
Category: "general",
},
{
Key: "default_scheme",
Value: "http",
Type: "string",
Category: "general",
},
{
Key: "enable_ssl_by_default",
Value: "false",
Type: "bool",
Category: "security",
},
}
for _, setting := range settings {
result := db.Where("key = ?", setting.Key).FirstOrCreate(&setting)
if result.Error != nil {
log.Printf("Failed to seed setting %s: %v", setting.Key, result.Error)
} else if result.RowsAffected > 0 {
fmt.Printf("✓ Created setting: %s = %s\n", setting.Key, setting.Value)
} else {
fmt.Printf(" Setting already exists: %s\n", setting.Key)
}
}
// Seed default admin user (for future authentication)
defaultAdminEmail := os.Getenv("CHARON_DEFAULT_ADMIN_EMAIL")
if defaultAdminEmail == "" {
defaultAdminEmail = "admin@localhost"
}
defaultAdminPassword := os.Getenv("CHARON_DEFAULT_ADMIN_PASSWORD")
// If a default password is not specified, leave the hashed placeholder (non-loginable)
forceAdmin := os.Getenv("CHARON_FORCE_DEFAULT_ADMIN") == "1"
user := models.User{
UUID: uuid.NewString(),
Email: defaultAdminEmail,
Name: "Administrator",
Role: "admin",
Enabled: true,
}
// If a default password provided, use SetPassword to generate a proper bcrypt hash
if defaultAdminPassword != "" {
if err := user.SetPassword(defaultAdminPassword); err != nil {
log.Printf("Failed to hash default admin password: %v", err)
}
} else {
// Keep previous behavior: using example hashed password (not valid)
user.PasswordHash = "$2a$10$example_hashed_password"
}
var existing models.User
// Find by email first
if err := db.Where("email = ?", user.Email).First(&existing).Error; err != nil {
// Not found -> create
result := db.Create(&user)
if result.Error != nil {
log.Printf("Failed to seed user: %v", result.Error)
} else if result.RowsAffected > 0 {
fmt.Printf("✓ Created default user: %s\n", user.Email)
}
} else {
// Found existing user - optionally update if forced
if forceAdmin {
existing.Email = user.Email
existing.Name = user.Name
existing.Role = user.Role
existing.Enabled = user.Enabled
if defaultAdminPassword != "" {
if err := existing.SetPassword(defaultAdminPassword); err == nil {
db.Save(&existing)
fmt.Printf("✓ Updated existing admin user password for: %s\n", existing.Email)
} else {
log.Printf("Failed to update existing admin password: %v", err)
}
} else {
db.Save(&existing)
fmt.Printf(" User already exists: %s\n", existing.Email)
}
} else {
fmt.Printf(" User already exists: %s\n", existing.Email)
}
}
// result handling is done inline above
fmt.Println("\n✓ Database seeding completed successfully!")
fmt.Println(" You can now start the application and see sample data.")
}
-77
View File
@@ -1,77 +0,0 @@
module github.com/Wikid82/charon/backend
go 1.25.4
require (
github.com/containrrr/shoutrrr v0.8.0
github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.45.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
-229
View File
@@ -1,229 +0,0 @@
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/onsi/ginkgo/v2 v2.9.5 h1:+6Hr4uxzP4XIUyAkg61dWBw8lb/gc4/X5luuxN/EC+Q=
github.com/onsi/ginkgo/v2 v2.9.5/go.mod h1:tvAoo1QUJwNEU2ITftXTpR7R1RbCzoZUOs3RonqW57k=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
-447
View File
@@ -1,447 +0,0 @@
mode: set
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:14.69,16.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:23.45,25.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:25.47,28.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:30.2,31.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:31.16,34.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:37.2,39.46 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:48.48,50.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:50.47,53.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:55.2,56.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:56.16,59.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:61.2,61.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:64.46,67.2 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:69.42,74.16 4 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:74.16,77.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:79.2,84.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:92.54,94.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:94.47,97.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:99.2,100.13 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:100.13,103.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:105.2,105.102 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:105.102,108.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/auth_handler.go:110.2,110.74 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:15.71,17.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:19.46,21.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:21.16,24.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:25.2,25.32 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:28.48,30.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:30.16,33.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:34.2,34.99 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:37.48,39.57 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:39.57,40.25 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:40.25,43.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:44.3,45.9 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:47.2,47.59 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:50.50,53.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:53.16,56.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:58.2,58.49 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:58.49,61.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:63.2,64.14 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:67.49,69.58 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:69.58,70.25 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:70.25,73.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:74.3,75.9 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/backup_handler.go:78.2,78.104 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:18.120,23.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:25.51,27.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:27.16,30.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:32.2,32.30 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:41.53,44.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:44.16,47.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:50.2,51.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:51.16,54.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:56.2,57.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:57.16,60.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:63.2,64.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:64.16,67.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:68.2,71.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:71.16,74.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:75.2,88.16 9 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:88.16,91.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:94.2,94.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:94.34,105.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:107.2,107.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:110.53,113.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:113.16,116.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:118.2,118.62 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:118.62,121.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:124.2,124.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:124.34,134.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/certificate_handler.go:136.2,136.64 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/docker_handler.go:14.77,16.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/docker_handler.go:18.60,20.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/docker_handler.go:22.56,25.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/docker_handler.go:25.16,28.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/docker_handler.go:30.2,30.35 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:18.85,23.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:25.46,27.68 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:27.68,30.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:31.2,31.32 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:34.48,39.49 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:39.49,42.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:44.2,48.51 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:48.51,51.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:54.2,54.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:54.34,64.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:66.2,66.36 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:69.48,72.72 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:72.72,74.35 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:74.35,84.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:87.2,87.82 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:87.82,90.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/domain_handler.go:91.2,91.59 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/health_handler.go:11.36,19.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:32.93,40.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:43.65,51.2 7 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:54.51,60.35 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:60.35,62.24 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:62.24,63.50 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:63.50,73.5 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:75.3,76.9 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:79.2,79.16 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:79.16,82.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:84.2,92.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:96.52,102.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:102.16,105.77 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:105.77,112.32 4 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:112.32,113.68 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:113.68,115.6 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:115.11,117.61 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:117.61,119.7 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:123.4,134.10 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:139.2,139.23 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:139.23,140.49 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:140.49,143.18 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:143.18,146.5 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:149.4,151.60 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:151.60,153.5 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:156.4,158.37 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:158.37,160.5 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:161.4,161.39 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:161.39,162.40 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:162.40,164.6 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:167.4,172.10 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:176.2,176.66 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:180.48,186.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:186.47,189.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:192.2,194.54 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:194.54,197.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:199.2,200.74 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:200.74,203.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:206.2,207.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:207.16,210.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:213.2,215.35 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:215.35,217.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:218.2,218.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:218.34,219.38 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:219.38,221.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:224.2,227.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:231.55,236.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:236.47,239.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:241.2,245.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:249.53,257.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:257.47,260.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:263.2,264.30 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:264.30,265.70 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:265.70,267.9 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:270.2,270.19 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:270.19,273.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:276.2,278.54 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:278.54,281.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:284.2,285.30 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:285.30,286.41 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:286.41,289.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:292.3,296.57 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:296.57,297.49 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:297.49,300.5 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:303.3,303.75 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:303.75,306.4 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:309.3,309.68 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:309.68,311.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:315.2,316.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:316.16,319.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:322.2,324.35 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:324.35,326.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:327.2,327.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:327.34,328.38 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:328.38,330.4 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:333.2,336.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:340.54,343.29 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:343.29,345.44 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:345.44,348.50 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:348.50,350.5 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:351.4,351.35 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:354.2,354.16 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:358.48,364.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:364.47,367.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:370.2,372.114 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:372.114,374.77 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:374.77,377.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:378.8,381.49 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:381.49,383.18 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:383.18,386.5 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:387.4,389.82 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:390.9,390.31 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:390.31,391.50 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:391.50,393.19 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:393.19,396.6 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:397.5,398.83 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:399.10,402.5 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:403.9,406.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:410.2,417.34 6 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:417.34,420.23 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:420.23,422.12 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:425.3,425.25 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:425.25,427.4 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:429.3,431.54 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:431.54,435.4 3 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:435.9,438.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:442.2,447.30 5 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:447.30,449.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:450.2,450.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:450.34,452.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:453.2,453.50 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:453.50,455.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:457.2,461.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:465.48,467.23 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:467.23,470.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:472.2,473.82 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:473.82,478.3 4 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:481.2,482.48 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:482.48,486.3 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:489.2,489.66 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:493.81,495.64 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:495.64,497.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:500.2,501.16 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:501.16,503.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:506.2,508.37 3 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:508.37,510.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:512.2,512.38 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:512.38,513.42 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:513.42,516.4 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:520.2,528.52 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:528.52,530.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:533.2,533.103 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:533.103,536.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:538.2,538.12 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:542.86,543.54 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:543.54,547.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:550.2,554.15 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:554.15,556.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:559.2,559.12 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/import_handler.go:562.40,565.2 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:19.64,21.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:23.44,25.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:25.16,28.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:29.2,29.29 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:32.44,50.16 6 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:50.16,51.25 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:51.25,54.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:55.3,56.9 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:59.2,65.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:68.48,71.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:71.16,72.56 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:72.56,75.4 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:76.3,77.9 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:82.2,83.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:83.16,86.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:87.2,90.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:90.16,94.3 3 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:95.2,97.53 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:97.53,101.3 3 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/logs_handler.go:102.2,105.24 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:14.89,16.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:18.52,21.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:21.16,24.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:25.2,25.38 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:28.58,30.49 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:30.49,33.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:34.2,34.72 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:37.61,38.50 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:38.50,41.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_handler.go:42.2,42.77 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:16.105,18.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:20.60,22.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:22.16,25.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:26.2,26.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:29.62,31.52 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:31.52,34.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:36.2,36.60 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:36.60,39.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:40.2,40.38 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:43.62,46.52 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:46.52,49.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:50.2,52.60 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:52.60,55.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:56.2,56.33 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:59.62,61.53 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:61.53,64.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:65.2,65.61 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:68.60,70.52 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:70.52,73.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:75.2,75.57 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:75.57,80.3 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/notification_provider_handler.go:81.2,81.67 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:24.120,30.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:33.68,40.2 6 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:43.49,45.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:45.16,48.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:50.2,50.30 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:54.51,56.48 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:56.48,59.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:61.2,64.32 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:64.32,66.3 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:68.2,68.48 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:68.48,71.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:73.2,73.27 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:73.27,74.73 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:74.73,77.64 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:77.64,79.5 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:80.4,81.10 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:86.2,86.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:86.34,97.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:99.2,99.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:103.48,107.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:107.16,110.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:112.2,112.29 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:116.51,120.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:120.16,123.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:125.2,125.47 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:125.47,128.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:130.2,130.47 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:130.47,133.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:135.2,135.27 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:135.27,136.73 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:136.73,139.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:142.2,142.29 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:146.51,150.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:150.16,153.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:155.2,155.50 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:155.50,158.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:160.2,160.27 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:160.27,161.73 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:161.73,164.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:168.2,168.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:168.34,178.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:180.2,180.63 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:184.59,190.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:190.47,193.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:195.2,195.83 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:195.83,198.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/proxy_host_handler.go:200.2,200.66 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:24.97,29.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:32.71,40.2 7 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:43.52,47.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:47.16,50.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:52.2,52.32 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:56.54,58.50 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:58.50,61.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:63.2,65.50 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:65.50,68.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:71.2,71.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:71.34,83.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:85.2,85.36 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:89.51,93.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:93.16,96.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:98.2,98.31 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:102.54,106.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:106.16,109.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:111.2,111.49 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:111.49,114.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:116.2,116.49 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:116.49,119.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:121.2,121.31 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:125.54,129.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:129.16,132.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:134.2,134.52 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:134.52,137.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:140.2,140.34 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:140.34,150.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:152.2,152.35 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:156.62,160.16 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:160.16,163.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:166.2,175.16 4 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:175.16,187.3 8 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:188.2,200.31 8 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:204.68,210.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:210.47,213.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:216.2,225.16 5 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:225.16,230.3 4 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/remote_server_handler.go:231.2,237.31 4 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:16.55,18.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:21.55,23.51 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:23.51,26.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:29.2,30.29 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:30.29,32.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:34.2,34.36 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:45.57,47.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:47.47,50.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:52.2,57.24 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:57.24,59.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:60.2,60.20 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:60.20,62.3 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:65.2,65.111 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:65.111,68.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/settings_handler.go:70.2,70.32 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/update_handler.go:14.71,16.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/update_handler.go:18.47,20.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/update_handler.go:20.16,23.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/update_handler.go:24.2,24.29 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/uptime_handler.go:15.71,17.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/uptime_handler.go:19.46,21.16 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/uptime_handler.go:21.16,24.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/uptime_handler.go:25.2,25.33 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/uptime_handler.go:28.52,33.16 4 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/uptime_handler.go:33.16,36.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/uptime_handler.go:37.2,37.32 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:18.47,20.2 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:22.58,28.2 5 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:31.54,33.71 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:33.71,36.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:38.2,40.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:50.45,53.71 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:53.71,56.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:58.2,58.15 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:58.15,61.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:64.2,65.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:65.47,68.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:71.2,80.55 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:80.55,83.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:86.2,94.50 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:94.50,95.48 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:95.48,97.4 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:99.3,99.155 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:99.155,101.4 1 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:102.3,102.13 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:105.2,105.16 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:105.16,108.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:110.2,117.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:121.56,123.13 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:123.13,126.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:128.2,130.107 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:130.107,133.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:135.2,135.49 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:139.50,141.13 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:141.13,144.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:146.2,147.56 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:147.56,150.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:152.2,158.4 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:168.53,170.13 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:170.13,173.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:175.2,176.47 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:176.47,179.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:182.2,183.56 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:183.56,186.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:189.2,191.121 3 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:191.121,194.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:196.2,196.15 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:196.15,199.3 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:202.2,202.29 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:202.29,203.32 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:203.32,206.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:207.3,207.47 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:207.47,210.4 2 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:213.2,216.23 1 1
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:216.23,219.3 2 0
github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers/user_handler.go:221.2,221.73 1 1
File diff suppressed because it is too large Load Diff
@@ -1,162 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AccessListHandler struct {
service *services.AccessListService
}
func NewAccessListHandler(db *gorm.DB) *AccessListHandler {
return &AccessListHandler{
service: services.NewAccessListService(db),
}
}
// Create handles POST /api/v1/access-lists
func (h *AccessListHandler) Create(c *gin.Context) {
var acl models.AccessList
if err := c.ShouldBindJSON(&acl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Create(&acl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, acl)
}
// List handles GET /api/v1/access-lists
func (h *AccessListHandler) List(c *gin.Context) {
acls, err := h.service.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, acls)
}
// Get handles GET /api/v1/access-lists/:id
func (h *AccessListHandler) Get(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
acl, err := h.service.GetByID(uint(id))
if err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, acl)
}
// Update handles PUT /api/v1/access-lists/:id
func (h *AccessListHandler) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
var updates models.AccessList
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Update(uint(id), &updates); err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Fetch updated record
acl, _ := h.service.GetByID(uint(id))
c.JSON(http.StatusOK, acl)
}
// Delete handles DELETE /api/v1/access-lists/:id
func (h *AccessListHandler) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
if err := h.service.Delete(uint(id)); err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
if err == services.ErrAccessListInUse {
c.JSON(http.StatusConflict, gin.H{"error": "access list is in use by proxy hosts"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "access list deleted"})
}
// TestIP handles POST /api/v1/access-lists/:id/test
func (h *AccessListHandler) TestIP(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
var req struct {
IPAddress string `json:"ip_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
allowed, reason, err := h.service.TestIP(uint(id), req.IPAddress)
if err != nil {
if err == services.ErrAccessListNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
return
}
if err == services.ErrInvalidIPAddress {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"allowed": allowed,
"reason": reason,
})
}
// GetTemplates handles GET /api/v1/access-lists/templates
func (h *AccessListHandler) GetTemplates(c *gin.Context) {
templates := h.service.GetTemplates()
c.JSON(http.StatusOK, templates)
}
@@ -1,415 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupAccessListTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
err = db.AutoMigrate(&models.AccessList{}, &models.ProxyHost{})
assert.NoError(t, err)
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAccessListHandler(db)
router.POST("/access-lists", handler.Create)
router.GET("/access-lists", handler.List)
router.GET("/access-lists/:id", handler.Get)
router.PUT("/access-lists/:id", handler.Update)
router.DELETE("/access-lists/:id", handler.Delete)
router.POST("/access-lists/:id/test", handler.TestIP)
router.GET("/access-lists/templates", handler.GetTemplates)
return router, db
}
func TestAccessListHandler_Create(t *testing.T) {
router, _ := setupAccessListTestRouter(t)
tests := []struct {
name string
payload map[string]interface{}
wantStatus int
}{
{
name: "create whitelist successfully",
payload: map[string]interface{}{
"name": "Office Whitelist",
"description": "Allow office IPs only",
"type": "whitelist",
"ip_rules": `[{"cidr":"192.168.1.0/24","description":"Office network"}]`,
"enabled": true,
},
wantStatus: http.StatusCreated,
},
{
name: "create geo whitelist successfully",
payload: map[string]interface{}{
"name": "US Only",
"type": "geo_whitelist",
"country_codes": "US,CA",
"enabled": true,
},
wantStatus: http.StatusCreated,
},
{
name: "create local network only",
payload: map[string]interface{}{
"name": "Local Network",
"type": "whitelist",
"local_network_only": true,
"enabled": true,
},
wantStatus: http.StatusCreated,
},
{
name: "fail with invalid type",
payload: map[string]interface{}{
"name": "Invalid",
"type": "invalid_type",
"enabled": true,
},
wantStatus: http.StatusBadRequest,
},
{
name: "fail with missing name",
payload: map[string]interface{}{
"type": "whitelist",
"enabled": true,
},
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.payload)
req := httptest.NewRequest(http.MethodPost, "/access-lists", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
if w.Code == http.StatusCreated {
var response models.AccessList
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.NotEmpty(t, response.UUID)
assert.Equal(t, tt.payload["name"], response.Name)
}
})
}
}
func TestAccessListHandler_List(t *testing.T) {
router, db := setupAccessListTestRouter(t)
// Create test data
acls := []models.AccessList{
{Name: "Test 1", Type: "whitelist", Enabled: true},
{Name: "Test 2", Type: "blacklist", Enabled: false},
}
for i := range acls {
acls[i].UUID = "test-uuid-" + string(rune(i))
db.Create(&acls[i])
}
req := httptest.NewRequest(http.MethodGet, "/access-lists", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response []models.AccessList
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Len(t, response, 2)
}
func TestAccessListHandler_Get(t *testing.T) {
router, db := setupAccessListTestRouter(t)
// Create test ACL
acl := models.AccessList{
UUID: "test-uuid",
Name: "Test ACL",
Type: "whitelist",
Enabled: true,
}
db.Create(&acl)
tests := []struct {
name string
id string
wantStatus int
}{
{
name: "get existing ACL",
id: "1",
wantStatus: http.StatusOK,
},
{
name: "get non-existent ACL",
id: "9999",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/access-lists/"+tt.id, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
if w.Code == http.StatusOK {
var response models.AccessList
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, acl.Name, response.Name)
}
})
}
}
func TestAccessListHandler_Update(t *testing.T) {
router, db := setupAccessListTestRouter(t)
// Create test ACL
acl := models.AccessList{
UUID: "test-uuid",
Name: "Original Name",
Type: "whitelist",
Enabled: true,
}
db.Create(&acl)
tests := []struct {
name string
id string
payload map[string]interface{}
wantStatus int
}{
{
name: "update successfully",
id: "1",
payload: map[string]interface{}{
"name": "Updated Name",
"description": "New description",
"enabled": false,
"type": "whitelist",
"ip_rules": `[{"cidr":"10.0.0.0/8","description":"Updated network"}]`,
},
wantStatus: http.StatusOK,
},
{
name: "update non-existent ACL",
id: "9999",
payload: map[string]interface{}{
"name": "Test",
"type": "whitelist",
"ip_rules": `[]`,
},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.payload)
req := httptest.NewRequest(http.MethodPut, "/access-lists/"+tt.id, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Logf("Response body: %s", w.Body.String())
}
assert.Equal(t, tt.wantStatus, w.Code)
if w.Code == http.StatusOK {
var response models.AccessList
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
if name, ok := tt.payload["name"].(string); ok {
assert.Equal(t, name, response.Name)
}
}
})
}
}
func TestAccessListHandler_Delete(t *testing.T) {
router, db := setupAccessListTestRouter(t)
// Create test ACL
acl := models.AccessList{
UUID: "test-uuid",
Name: "Test ACL",
Type: "whitelist",
Enabled: true,
}
db.Create(&acl)
// Create ACL in use
aclInUse := models.AccessList{
UUID: "in-use-uuid",
Name: "In Use ACL",
Type: "whitelist",
Enabled: true,
}
db.Create(&aclInUse)
host := models.ProxyHost{
UUID: "host-uuid",
Name: "Test Host",
DomainNames: "test.com",
ForwardHost: "localhost",
ForwardPort: 8080,
AccessListID: &aclInUse.ID,
}
db.Create(&host)
tests := []struct {
name string
id string
wantStatus int
}{
{
name: "delete successfully",
id: "1",
wantStatus: http.StatusOK,
},
{
name: "fail to delete ACL in use",
id: "2",
wantStatus: http.StatusConflict,
},
{
name: "delete non-existent ACL",
id: "9999",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/access-lists/"+tt.id, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
})
}
}
func TestAccessListHandler_TestIP(t *testing.T) {
router, db := setupAccessListTestRouter(t)
// Create test ACL
acl := models.AccessList{
UUID: "test-uuid",
Name: "Test Whitelist",
Type: "whitelist",
IPRules: `[{"cidr":"192.168.1.0/24","description":"Test network"}]`,
Enabled: true,
}
db.Create(&acl)
tests := []struct {
name string
id string
payload map[string]string
wantStatus int
}{
{
name: "test IP in whitelist",
id: "1", // Use numeric ID
payload: map[string]string{"ip_address": "192.168.1.100"},
wantStatus: http.StatusOK,
},
{
name: "test IP not in whitelist",
id: "1",
payload: map[string]string{"ip_address": "10.0.0.1"},
wantStatus: http.StatusOK,
},
{
name: "test invalid IP",
id: "1",
payload: map[string]string{"ip_address": "invalid"},
wantStatus: http.StatusBadRequest,
},
{
name: "test non-existent ACL",
id: "9999",
payload: map[string]string{"ip_address": "192.168.1.100"},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.payload)
req := httptest.NewRequest(http.MethodPost, "/access-lists/"+tt.id+"/test", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatus, w.Code)
if w.Code == http.StatusOK {
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Contains(t, response, "allowed")
assert.Contains(t, response, "reason")
}
})
}
}
func TestAccessListHandler_GetTemplates(t *testing.T) {
router, _ := setupAccessListTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/access-lists/templates", nil)
w := httptest.NewRecorder()
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.NotEmpty(t, response)
assert.Greater(t, len(response), 0)
// Verify template structure
for _, template := range response {
assert.Contains(t, template, "name")
assert.Contains(t, template, "description")
assert.Contains(t, template, "type")
}
}
@@ -1,111 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
authService *services.AuthService
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, err := h.authService.Login(req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// Set cookie
c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod
c.JSON(http.StatusOK, gin.H{"token": token})
}
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name" binding:"required"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.authService.Register(req.Email, req.Password, req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, user)
}
func (h *AuthHandler) Logout(c *gin.Context) {
c.SetCookie("auth_token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
func (h *AuthHandler) Me(c *gin.Context) {
userID, _ := c.Get("userID")
role, _ := c.Get("role")
u, err := h.authService.GetUserByID(userID.(uint))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"role": role,
"name": u.Name,
"email": u.Email,
})
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, exists := c.Get("userID")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
}
@@ -1,295 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"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"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) {
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.User{}, &models.Setting{})
cfg := config.Config{JWTSecret: "test-secret"}
authService := services.NewAuthService(db, cfg)
return NewAuthHandler(authService), db
}
func TestAuthHandler_Login(t *testing.T) {
handler, db := setupAuthHandler(t)
// Create user
user := &models.User{
UUID: uuid.NewString(),
Email: "test@example.com",
Name: "Test User",
}
user.SetPassword("password123")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/login", handler.Login)
// Success
body := map[string]string{
"email": "test@example.com",
"password": "password123",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "token")
}
func TestAuthHandler_Login_Errors(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/login", handler.Login)
// 1. Invalid JSON
req := httptest.NewRequest("POST", "/login", bytes.NewBufferString("invalid"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 2. Invalid Credentials
body := map[string]string{
"email": "nonexistent@example.com",
"password": "wrong",
}
jsonBody, _ := json.Marshal(body)
req = httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthHandler_Register(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/register", handler.Register)
body := map[string]string{
"email": "new@example.com",
"password": "password123",
"name": "New User",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), "new@example.com")
}
func TestAuthHandler_Register_Duplicate(t *testing.T) {
handler, db := setupAuthHandler(t)
db.Create(&models.User{UUID: uuid.NewString(), Email: "dup@example.com", Name: "Dup"})
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/register", handler.Register)
body := map[string]string{
"email": "dup@example.com",
"password": "password123",
"name": "Dup User",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestAuthHandler_Logout(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/logout", handler.Logout)
req := httptest.NewRequest("POST", "/logout", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Logged out")
// Check cookie
cookie := w.Result().Cookies()[0]
assert.Equal(t, "auth_token", cookie.Name)
assert.Equal(t, -1, cookie.MaxAge)
}
func TestAuthHandler_Me(t *testing.T) {
handler, db := setupAuthHandler(t)
// Create user that matches the middleware ID
user := &models.User{
UUID: uuid.NewString(),
Email: "me@example.com",
Name: "Me User",
Role: "admin",
}
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
// Simulate middleware
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Set("role", user.Role)
c.Next()
})
r.GET("/me", handler.Me)
req := httptest.NewRequest("GET", "/me", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Equal(t, float64(user.ID), resp["user_id"])
assert.Equal(t, "admin", resp["role"])
assert.Equal(t, "Me User", resp["name"])
assert.Equal(t, "me@example.com", resp["email"])
}
func TestAuthHandler_Me_NotFound(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", uint(999)) // Non-existent ID
c.Next()
})
r.GET("/me", handler.Me)
req := httptest.NewRequest("GET", "/me", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestAuthHandler_ChangePassword(t *testing.T) {
handler, db := setupAuthHandler(t)
// Create user
user := &models.User{
UUID: uuid.NewString(),
Email: "change@example.com",
Name: "Change User",
}
user.SetPassword("oldpassword")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
// Simulate middleware
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.POST("/change-password", handler.ChangePassword)
body := map[string]string{
"old_password": "oldpassword",
"new_password": "newpassword123",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Password updated successfully")
// Verify password changed
var updatedUser models.User
db.First(&updatedUser, user.ID)
assert.True(t, updatedUser.CheckPassword("newpassword123"))
}
func TestAuthHandler_ChangePassword_WrongOld(t *testing.T) {
handler, db := setupAuthHandler(t)
user := &models.User{UUID: uuid.NewString(), Email: "wrong@example.com"}
user.SetPassword("correct")
db.Create(user)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("userID", user.ID)
c.Next()
})
r.POST("/change-password", handler.ChangePassword)
body := map[string]string{
"old_password": "wrong",
"new_password": "newpassword",
}
jsonBody, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestAuthHandler_ChangePassword_Errors(t *testing.T) {
handler, _ := setupAuthHandler(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/change-password", handler.ChangePassword)
// 1. BindJSON error (checked before auth)
req, _ := http.NewRequest("POST", "/change-password", bytes.NewBufferString("invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// 2. Unauthorized (valid JSON but no user in context)
body := map[string]string{
"old_password": "oldpassword",
"new_password": "newpassword123",
}
jsonBody, _ := json.Marshal(body)
req, _ = http.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
@@ -1,79 +0,0 @@
package handlers
import (
"net/http"
"os"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type BackupHandler struct {
service *services.BackupService
}
func NewBackupHandler(service *services.BackupService) *BackupHandler {
return &BackupHandler{service: service}
}
func (h *BackupHandler) List(c *gin.Context) {
backups, err := h.service.ListBackups()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backups"})
return
}
c.JSON(http.StatusOK, backups)
}
func (h *BackupHandler) Create(c *gin.Context) {
filename, err := h.service.CreateBackup()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"})
}
func (h *BackupHandler) Delete(c *gin.Context) {
filename := c.Param("filename")
if err := h.service.DeleteBackup(filename); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Backup deleted"})
}
func (h *BackupHandler) Download(c *gin.Context) {
filename := c.Param("filename")
path, err := h.service.GetBackupPath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(path); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(path)
}
func (h *BackupHandler) Restore(c *gin.Context) {
filename := c.Param("filename")
if err := h.service.RestoreBackup(filename); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
return
}
// In a real scenario, we might want to trigger a restart here
c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."})
}
@@ -1,330 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/services"
)
func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string) {
t.Helper()
// Create temp directories
tmpDir, err := os.MkdirTemp("", "cpm-backup-test")
require.NoError(t, err)
// 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/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, "charon.db")
// Create a dummy DB file to back up
err = os.WriteFile(dbPath, []byte("dummy db content"), 0644)
require.NoError(t, err)
cfg := &config.Config{
DatabasePath: dbPath,
}
svc := services.NewBackupService(cfg)
h := NewBackupHandler(svc)
r := gin.New()
api := r.Group("/api/v1")
// Manually register routes since we don't have a RegisterRoutes method on the handler yet?
// Wait, I didn't check if I added RegisterRoutes to BackupHandler.
// In routes.go I did:
// backupHandler := handlers.NewBackupHandler(backupService)
// backups := api.Group("/backups")
// backups.GET("", backupHandler.List)
// ...
// So the handler doesn't have RegisterRoutes. I'll register manually here.
backups := api.Group("/backups")
backups.GET("", h.List)
backups.POST("", h.Create)
backups.POST("/:filename/restore", h.Restore)
backups.DELETE("/:filename", h.Delete)
backups.GET("/:filename/download", h.Download)
return r, svc, tmpDir
}
func TestBackupLifecycle(t *testing.T) {
router, _, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// 1. List backups (should be empty)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// Check empty list
// ...
// 2. Create backup
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var result map[string]string
err := json.Unmarshal(resp.Body.Bytes(), &result)
require.NoError(t, err)
filename := result["filename"]
require.NotEmpty(t, filename)
// 3. List backups (should have 1)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// Verify list contains filename
// 4. Restore backup
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 5. Download backup
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// Content-Type might vary depending on implementation (application/octet-stream or zip)
// require.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
// 6. Delete backup
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 7. List backups (should be empty again)
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var list []interface{}
json.Unmarshal(resp.Body.Bytes(), &list)
require.Empty(t, list)
// 8. Delete non-existent backup
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 9. Restore non-existent backup
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/missing.zip/restore", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 10. Download non-existent backup
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/missing.zip/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
}
func TestBackupHandler_Errors(t *testing.T) {
router, svc, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// 1. List Error (remove backup dir to cause ReadDir error)
// Note: Service now handles missing dir gracefully by returning empty list
os.RemoveAll(svc.BackupDir)
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var list []interface{}
json.Unmarshal(resp.Body.Bytes(), &list)
require.Empty(t, list)
// 4. Delete Error (Not Found)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
}
func TestBackupHandler_List_Success(t *testing.T) {
router, _, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// Create a backup first
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
// Now list should return it
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var backups []services.BackupFile
err := json.Unmarshal(resp.Body.Bytes(), &backups)
require.NoError(t, err)
require.Len(t, backups, 1)
require.Contains(t, backups[0].Filename, "backup_")
}
func TestBackupHandler_Create_Success(t *testing.T) {
router, _, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var result map[string]string
json.Unmarshal(resp.Body.Bytes(), &result)
require.NotEmpty(t, result["filename"])
require.Contains(t, result["filename"], "backup_")
}
func TestBackupHandler_Download_Success(t *testing.T) {
router, _, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// Create backup
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var result map[string]string
json.Unmarshal(resp.Body.Bytes(), &result)
filename := result["filename"]
// Download it
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
require.Contains(t, resp.Header().Get("Content-Type"), "application")
}
func TestBackupHandler_PathTraversal(t *testing.T) {
router, _, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// Try path traversal in Delete
req := httptest.NewRequest(http.MethodDelete, "/api/v1/backups/../../../etc/passwd", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Try path traversal in Download
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/../../../etc/passwd/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Contains(t, []int{http.StatusBadRequest, http.StatusNotFound}, resp.Code)
// Try path traversal in Restore
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/../../../etc/passwd/restore", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
}
func TestBackupHandler_Download_InvalidPath(t *testing.T) {
router, _, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// Request with path traversal attempt
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups/../invalid/download", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should be BadRequest due to path validation failure
require.Contains(t, []int{http.StatusBadRequest, http.StatusNotFound}, resp.Code)
}
func TestBackupHandler_Create_ServiceError(t *testing.T) {
router, svc, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// Remove write permissions on backup dir to force create error
os.Chmod(svc.BackupDir, 0444)
defer os.Chmod(svc.BackupDir, 0755)
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should fail with 500 due to permission error
require.Contains(t, []int{http.StatusInternalServerError, http.StatusCreated}, resp.Code)
}
func TestBackupHandler_Delete_InternalError(t *testing.T) {
router, svc, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// Create a backup first
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var result map[string]string
json.Unmarshal(resp.Body.Bytes(), &result)
filename := result["filename"]
// Make backup dir read-only to cause delete error (not NotExist)
os.Chmod(svc.BackupDir, 0444)
defer os.Chmod(svc.BackupDir, 0755)
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should fail with 500 due to permission error (not 404)
require.Contains(t, []int{http.StatusInternalServerError, http.StatusOK}, resp.Code)
}
func TestBackupHandler_Restore_InternalError(t *testing.T) {
router, svc, tmpDir := setupBackupTest(t)
defer os.RemoveAll(tmpDir)
// Create a backup first
req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var result map[string]string
json.Unmarshal(resp.Body.Bytes(), &result)
filename := result["filename"]
// Make data dir read-only to cause restore error
os.Chmod(svc.DataDir, 0444)
defer os.Chmod(svc.DataDir, 0755)
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should fail with 500 due to permission error
require.Contains(t, []int{http.StatusInternalServerError, http.StatusOK}, resp.Code)
}
@@ -1,137 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/Wikid82/charon/backend/internal/services"
)
type CertificateHandler struct {
service *services.CertificateService
notificationService *services.NotificationService
}
func NewCertificateHandler(service *services.CertificateService, ns *services.NotificationService) *CertificateHandler {
return &CertificateHandler{
service: service,
notificationService: ns,
}
}
func (h *CertificateHandler) List(c *gin.Context) {
certs, err := h.service.ListCertificates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, certs)
}
type UploadCertificateRequest struct {
Name string `form:"name" binding:"required"`
Certificate string `form:"certificate"` // PEM content
PrivateKey string `form:"private_key"` // PEM content
}
func (h *CertificateHandler) Upload(c *gin.Context) {
// Handle multipart form
name := c.PostForm("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
// Read files
certFile, err := c.FormFile("certificate_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
return
}
keyFile, err := c.FormFile("key_file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"})
return
}
// Open and read content
certSrc, err := certFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
return
}
defer func() { _ = certSrc.Close() }()
keySrc, err := keyFile.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
return
}
defer func() { _ = keySrc.Close() }()
// Read to string
// Limit size to avoid DoS (e.g. 1MB)
certBytes := make([]byte, 1024*1024)
n, _ := certSrc.Read(certBytes)
certPEM := string(certBytes[:n])
keyBytes := make([]byte, 1024*1024)
n, _ = keySrc.Read(keyBytes)
keyPEM := string(keyBytes[:n])
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"cert",
"Certificate Uploaded",
fmt.Sprintf("Certificate %s uploaded", cert.Name),
map[string]interface{}{
"Name": cert.Name,
"Domains": cert.Domains,
"Action": "uploaded",
},
)
}
c.JSON(http.StatusCreated, cert)
}
func (h *CertificateHandler) Delete(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.service.DeleteCertificate(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"cert",
"Certificate Deleted",
fmt.Sprintf("Certificate ID %d deleted", id),
map[string]interface{}{
"ID": id,
"Action": "deleted",
},
)
}
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
}
@@ -1,389 +0,0 @@
package handlers
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"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"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func generateTestCert(t *testing.T, domain string) []byte {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate private key: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: domain,
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
}
func TestCertificateHandler_List(t *testing.T) {
// Setup temp dir
tmpDir := t.TempDir()
caddyDir := filepath.Join(tmpDir, "caddy", "certificates", "acme-v02.api.letsencrypt.org-directory")
err := os.MkdirAll(caddyDir, 0755)
require.NoError(t, err)
// Setup in-memory DB
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/certificates", handler.List)
req, _ := http.NewRequest("GET", "/certificates", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var certs []services.CertificateInfo
err = json.Unmarshal(w.Body.Bytes(), &certs)
assert.NoError(t, err)
assert.Empty(t, certs)
}
func TestCertificateHandler_Upload(t *testing.T) {
// Setup
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/certificates", handler.Upload)
// Prepare Multipart Request
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("name", "Test Cert")
certPEM := generateTestCert(t, "test.com")
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
part.Write(certPEM)
part, _ = writer.CreateFormFile("key_file", "key.pem")
part.Write([]byte("FAKE KEY")) // Service doesn't validate key structure strictly yet, just PEM decoding?
// Actually service does: block, _ := pem.Decode([]byte(certPEM)) for cert.
// It doesn't seem to validate keyPEM in UploadCertificate, just stores it.
writer.Close()
req, _ := http.NewRequest("POST", "/certificates", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var cert models.SSLCertificate
err = json.Unmarshal(w.Body.Bytes(), &cert)
assert.NoError(t, err)
assert.Equal(t, "Test Cert", cert.Name)
}
func TestCertificateHandler_Delete(t *testing.T) {
// Setup
tmpDir := t.TempDir()
// Use WAL mode and busy timeout for better concurrency with race detector
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
// Seed a cert
cert := models.SSLCertificate{
UUID: "test-uuid",
Name: "To Delete",
}
err = db.Create(&cert).Error
require.NoError(t, err)
require.NotZero(t, cert.ID)
service := services.NewCertificateService(tmpDir, db)
// Allow background sync goroutine to complete before testing
time.Sleep(50 * time.Millisecond)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.DELETE("/certificates/:id", handler.Delete)
req, _ := http.NewRequest("DELETE", "/certificates/"+strconv.Itoa(int(cert.ID)), nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify deletion
var deletedCert models.SSLCertificate
err = db.First(&deletedCert, cert.ID).Error
assert.Error(t, err)
assert.Equal(t, gorm.ErrRecordNotFound, err)
}
func TestCertificateHandler_Upload_Errors(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/certificates", handler.Upload)
// Test invalid multipart (missing files)
req, _ := http.NewRequest("POST", "/certificates", bytes.NewBufferString("invalid"))
req.Header.Set("Content-Type", "multipart/form-data")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Test missing certificate file
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Missing Cert")
part, _ := writer.CreateFormFile("key_file", "key.pem")
part.Write([]byte("KEY"))
writer.Close()
req, _ = http.NewRequest("POST", "/certificates", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCertificateHandler_Delete_NotFound(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.NotificationProvider{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.DELETE("/certificates/:id", handler.Delete)
req, _ := http.NewRequest("DELETE", "/certificates/99999", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Service returns gorm.ErrRecordNotFound, handler should convert to 500 or 404
assert.True(t, w.Code >= 400)
}
func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.DELETE("/certificates/:id", handler.Delete)
req, _ := http.NewRequest("DELETE", "/certificates/invalid", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestCertificateHandler_Upload_InvalidCertificate(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/certificates", handler.Upload)
// Test invalid certificate content
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Invalid Cert")
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
part.Write([]byte("INVALID CERTIFICATE DATA"))
part, _ = writer.CreateFormFile("key_file", "key.pem")
part.Write([]byte("INVALID KEY DATA"))
writer.Close()
req, _ := http.NewRequest("POST", "/certificates", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Should fail with 500 due to invalid certificate parsing
assert.Contains(t, []int{http.StatusInternalServerError, http.StatusBadRequest}, w.Code)
}
func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/certificates", handler.Upload)
// Test missing key file
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Cert Without Key")
certPEM := generateTestCert(t, "test.com")
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
part.Write(certPEM)
writer.Close()
req, _ := http.NewRequest("POST", "/certificates", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "key_file")
}
func TestCertificateHandler_Upload_MissingName(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/certificates", handler.Upload)
// Test missing name field
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
certPEM := generateTestCert(t, "test.com")
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
part.Write(certPEM)
part, _ = writer.CreateFormFile("key_file", "key.pem")
part.Write([]byte("FAKE KEY"))
writer.Close()
req, _ := http.NewRequest("POST", "/certificates", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Handler should accept even without name (service might generate one)
// But let's check what the actual behavior is
assert.Contains(t, []int{http.StatusCreated, http.StatusBadRequest}, w.Code)
}
func TestCertificateHandler_List_WithCertificates(t *testing.T) {
tmpDir := t.TempDir()
caddyDir := filepath.Join(tmpDir, "caddy", "certificates", "acme-v02.api.letsencrypt.org-directory")
err := os.MkdirAll(caddyDir, 0755)
require.NoError(t, err)
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
// Seed a certificate in DB
cert := models.SSLCertificate{
UUID: "test-uuid",
Name: "Test Cert",
}
err = db.Create(&cert).Error
require.NoError(t, err)
service := services.NewCertificateService(tmpDir, db)
ns := services.NewNotificationService(db)
handler := NewCertificateHandler(service, ns)
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/certificates", handler.List)
req, _ := http.NewRequest("GET", "/certificates", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var certs []services.CertificateInfo
err = json.Unmarshal(w.Body.Bytes(), &certs)
assert.NoError(t, err)
assert.NotEmpty(t, certs)
}
@@ -1,52 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type DockerHandler struct {
dockerService *services.DockerService
remoteServerService *services.RemoteServerService
}
func NewDockerHandler(dockerService *services.DockerService, remoteServerService *services.RemoteServerService) *DockerHandler {
return &DockerHandler{
dockerService: dockerService,
remoteServerService: remoteServerService,
}
}
func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/docker/containers", h.ListContainers)
}
func (h *DockerHandler) ListContainers(c *gin.Context) {
host := c.Query("host")
serverID := c.Query("server_id")
// If server_id is provided, look up the remote server
if serverID != "" {
server, err := h.remoteServerService.GetByUUID(serverID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Remote server not found"})
return
}
// Construct Docker host string
// Assuming TCP for now as that's what RemoteServer supports (Host/Port)
// TODO: Support SSH if/when RemoteServer supports it
host = fmt.Sprintf("tcp://%s:%d", server.Host, server.Port)
}
containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()})
return
}
c.JSON(http.StatusOK, containers)
}
@@ -1,171 +0,0 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"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"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupDockerTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *services.RemoteServerService) {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.RemoteServer{}))
rsService := services.NewRemoteServerService(db)
gin.SetMode(gin.TestMode)
r := gin.New()
return r, db, rsService
}
func TestDockerHandler_ListContainers(t *testing.T) {
// We can't easily mock the DockerService without an interface,
// and the DockerService depends on the real Docker client.
// So we'll just test that the handler is wired up correctly,
// even if it returns an error because Docker isn't running in the test env.
svc, _ := services.NewDockerService()
// svc might be nil if docker is not available, but NewDockerHandler handles nil?
// Actually NewDockerHandler just stores it.
// If svc is nil, ListContainers will panic.
// So we only run this if svc is not nil.
if svc == nil {
t.Skip("Docker not available")
}
r, _, rsService := setupDockerTestRouter(t)
h := NewDockerHandler(svc, rsService)
h.RegisterRoutes(r.Group("/"))
req, _ := http.NewRequest("GET", "/docker/containers", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// It might return 200 or 500 depending on if ListContainers succeeds
assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code)
}
func TestDockerHandler_ListContainers_NonExistentServerID(t *testing.T) {
svc, _ := services.NewDockerService()
if svc == nil {
t.Skip("Docker not available")
}
r, _, rsService := setupDockerTestRouter(t)
h := NewDockerHandler(svc, rsService)
h.RegisterRoutes(r.Group("/"))
// Request with non-existent server_id
req, _ := http.NewRequest("GET", "/docker/containers?server_id=non-existent-uuid", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "Remote server not found")
}
func TestDockerHandler_ListContainers_WithServerID(t *testing.T) {
svc, _ := services.NewDockerService()
if svc == nil {
t.Skip("Docker not available")
}
r, db, rsService := setupDockerTestRouter(t)
// Create a remote server
server := models.RemoteServer{
UUID: uuid.New().String(),
Name: "Test Docker Server",
Host: "docker.example.com",
Port: 2375,
Scheme: "",
Enabled: true,
}
require.NoError(t, db.Create(&server).Error)
h := NewDockerHandler(svc, rsService)
h.RegisterRoutes(r.Group("/"))
// Request with valid server_id (will fail to connect, but shouldn't error on lookup)
req, _ := http.NewRequest("GET", "/docker/containers?server_id="+server.UUID, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Should attempt to connect and likely fail with 500 (not 404)
assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code)
if w.Code == http.StatusInternalServerError {
assert.Contains(t, w.Body.String(), "Failed to list containers")
}
}
func TestDockerHandler_ListContainers_WithHostQuery(t *testing.T) {
svc, _ := services.NewDockerService()
if svc == nil {
t.Skip("Docker not available")
}
r, _, rsService := setupDockerTestRouter(t)
h := NewDockerHandler(svc, rsService)
h.RegisterRoutes(r.Group("/"))
// Request with custom host parameter
req, _ := http.NewRequest("GET", "/docker/containers?host=tcp://invalid-host:2375", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// Should attempt to connect and fail with 500
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "Failed to list containers")
}
func TestDockerHandler_RegisterRoutes(t *testing.T) {
svc, _ := services.NewDockerService()
if svc == nil {
t.Skip("Docker not available")
}
r, _, rsService := setupDockerTestRouter(t)
h := NewDockerHandler(svc, rsService)
h.RegisterRoutes(r.Group("/"))
// Verify route is registered
routes := r.Routes()
found := false
for _, route := range routes {
if route.Path == "/docker/containers" && route.Method == "GET" {
found = true
break
}
}
assert.True(t, found, "Expected /docker/containers GET route to be registered")
}
func TestDockerHandler_NewDockerHandler(t *testing.T) {
svc, _ := services.NewDockerService()
if svc == nil {
t.Skip("Docker not available")
}
_, _, rsService := setupDockerTestRouter(t)
h := NewDockerHandler(svc, rsService)
assert.NotNil(t, h)
assert.NotNil(t, h.dockerService)
assert.NotNil(t, h.remoteServerService)
}
@@ -1,92 +0,0 @@
package handlers
import (
"fmt"
"net/http"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DomainHandler struct {
DB *gorm.DB
notificationService *services.NotificationService
}
func NewDomainHandler(db *gorm.DB, ns *services.NotificationService) *DomainHandler {
return &DomainHandler{
DB: db,
notificationService: ns,
}
}
func (h *DomainHandler) List(c *gin.Context) {
var domains []models.Domain
if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
return
}
c.JSON(http.StatusOK, domains)
}
func (h *DomainHandler) Create(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
domain := models.Domain{
Name: input.Name,
}
if err := h.DB.Create(&domain).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"domain",
"Domain Added",
fmt.Sprintf("Domain %s added", domain.Name),
map[string]interface{}{
"Name": domain.Name,
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, domain)
}
func (h *DomainHandler) Delete(c *gin.Context) {
id := c.Param("id")
var domain models.Domain
if err := h.DB.Where("uuid = ?", id).First(&domain).Error; err == nil {
// Send Notification before delete (or after if we keep the name)
if h.notificationService != nil {
h.notificationService.SendExternal(
"domain",
"Domain Deleted",
fmt.Sprintf("Domain %s deleted", domain.Name),
map[string]interface{}{
"Name": domain.Name,
"Action": "deleted",
},
)
}
}
if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
}
@@ -1,160 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.Domain{}))
ns := services.NewNotificationService(db)
h := NewDomainHandler(db, ns)
r := gin.New()
// Manually register routes since DomainHandler doesn't have a RegisterRoutes method yet
// or we can just register them here for testing
r.GET("/api/v1/domains", h.List)
r.POST("/api/v1/domains", h.Create)
r.DELETE("/api/v1/domains/:id", h.Delete)
return r, db
}
func TestDomainLifecycle(t *testing.T) {
router, _ := setupDomainTestRouter(t)
// 1. Create Domain
body := `{"name":"example.com"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var created models.Domain
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
require.Equal(t, "example.com", created.Name)
require.NotEmpty(t, created.UUID)
// 2. List Domains
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var list []models.Domain
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
require.Len(t, list, 1)
require.Equal(t, "example.com", list[0].Name)
// 3. Delete Domain
req = httptest.NewRequest(http.MethodDelete, "/api/v1/domains/"+created.UUID, nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// 4. Verify Deletion
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
require.Len(t, list, 0)
}
func TestDomainErrors(t *testing.T) {
router, _ := setupDomainTestRouter(t)
// 1. Create Invalid JSON
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{invalid}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// 2. Create Missing Name
req = httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
func TestDomainDelete_NotFound(t *testing.T) {
router, _ := setupDomainTestRouter(t)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/domains/nonexistent-uuid", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Handler may return 200 with deleted=true even if not found (soft delete behavior)
require.True(t, resp.Code == http.StatusOK || resp.Code == http.StatusNotFound)
}
func TestDomainCreate_Duplicate(t *testing.T) {
router, db := setupDomainTestRouter(t)
// Create first domain
body := `{"name":"duplicate.com"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
// Try creating duplicate
req = httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should error - could be 409 Conflict or 500 depending on implementation
require.True(t, resp.Code >= 400, "Expected error status for duplicate domain")
// Verify only one exists
var count int64
db.Model(&models.Domain{}).Where("name = ?", "duplicate.com").Count(&count)
require.Equal(t, int64(1), count)
}
func TestDomainList_Empty(t *testing.T) {
router, _ := setupDomainTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var list []models.Domain
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
require.Empty(t, list)
}
func TestDomainCreate_LongName(t *testing.T) {
router, _ := setupDomainTestRouter(t)
longName := strings.Repeat("a", 300) + ".com"
body := `{"name":"` + longName + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// Should succeed (database will truncate or accept)
require.True(t, resp.Code == http.StatusCreated || resp.Code >= 400)
}
@@ -1,368 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"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 {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
// Auto migrate
db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.RemoteServer{},
&models.ImportSession{},
)
return db
}
func TestRemoteServerHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test List
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var servers []models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &servers)
assert.NoError(t, err)
assert.Len(t, servers, 1)
assert.Equal(t, "Test Server", servers[0].Name)
}
func TestRemoteServerHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Create
serverData := map[string]interface{}{
"name": "New Server",
"provider": "generic",
"host": "192.168.1.100",
"port": 3000,
"enabled": true,
}
body, _ := json.Marshal(serverData)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var server models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &server)
assert.NoError(t, err)
assert.Equal(t, "New Server", server.Name)
assert.NotEmpty(t, server.UUID)
}
func TestRemoteServerHandler_TestConnection(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 99999, // Invalid port to test failure
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test connection
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/"+server.UUID+"/test", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
assert.False(t, result["reachable"].(bool))
assert.NotEmpty(t, result["error"])
}
func TestRemoteServerHandler_Get(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Get
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var fetched models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &fetched)
assert.NoError(t, err)
assert.Equal(t, server.UUID, fetched.UUID)
}
func TestRemoteServerHandler_Update(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Update
updateData := map[string]interface{}{
"name": "Updated Server",
"provider": "generic",
"host": "10.0.0.1",
"port": 9000,
"enabled": false,
}
body, _ := json.Marshal(updateData)
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/remote-servers/"+server.UUID, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &updated)
assert.NoError(t, err)
assert.Equal(t, "Updated Server", updated.Name)
assert.Equal(t, "generic", updated.Provider)
assert.False(t, updated.Enabled)
}
func TestRemoteServerHandler_Delete(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test server
server := &models.RemoteServer{
UUID: uuid.NewString(),
Name: "Test Server",
Provider: "docker",
Host: "localhost",
Port: 8080,
Enabled: true,
}
db.Create(server)
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Delete
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/v1/remote-servers/"+server.UUID, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
// Verify Delete
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
router.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusNotFound, w2.Code)
}
func TestProxyHostHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
// Create test proxy host
host := &models.ProxyHost{
UUID: uuid.NewString(),
Name: "Test Host",
DomainNames: "test.local",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 3000,
Enabled: true,
}
db.Create(host)
ns := services.NewNotificationService(db)
handler := handlers.NewProxyHostHandler(db, nil, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test List
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/proxy-hosts", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var hosts []models.ProxyHost
err := json.Unmarshal(w.Body.Bytes(), &hosts)
assert.NoError(t, err)
assert.Len(t, hosts, 1)
assert.Equal(t, "Test Host", hosts[0].Name)
}
func TestProxyHostHandler_Create(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
ns := services.NewNotificationService(db)
handler := handlers.NewProxyHostHandler(db, nil, ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Test Create
hostData := map[string]interface{}{
"name": "New Host",
"domain_names": "new.local",
"forward_scheme": "http",
"forward_host": "192.168.1.200",
"forward_port": 8080,
"enabled": true,
}
body, _ := json.Marshal(hostData)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/proxy-hosts", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var host models.ProxyHost
err := json.Unmarshal(w.Body.Bytes(), &host)
assert.NoError(t, err)
assert.Equal(t, "New Host", host.Name)
assert.Equal(t, "new.local", host.DomainNames)
assert.NotEmpty(t, host.UUID)
}
func TestHealthHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/health", handlers.HealthHandler)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/health", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]string
err := json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
assert.Equal(t, "ok", result["status"])
}
func TestRemoteServerHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB()
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
router := gin.New()
handler.RegisterRoutes(router.Group("/api/v1"))
// Get non-existent
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Update non-existent
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/non-existent", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Delete non-existent
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
@@ -1,38 +0,0 @@
package handlers
import (
"net"
"net/http"
"github.com/Wikid82/charon/backend/internal/version"
"github.com/gin-gonic/gin"
)
// getLocalIP returns the non-loopback local IP of the host
func getLocalIP() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, address := range addrs {
// check the address type and if it is not a loopback then return it
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return ipnet.IP.String()
}
}
}
return ""
}
// HealthHandler responds with basic service metadata for uptime checks.
func HealthHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": version.Name,
"version": version.Version,
"git_commit": version.GitCommit,
"build_time": version.BuildTime,
"internal_ip": getLocalIP(),
})
}
@@ -1,29 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"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)
req, _ := http.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
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"])
}
@@ -1,709 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/internal/util"
)
// ImportHandler handles Caddyfile import operations.
type ImportHandler struct {
db *gorm.DB
proxyHostSvc *services.ProxyHostService
importerservice *caddy.Importer
importDir string
mountPath string
}
// NewImportHandler creates a new import handler.
func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
return &ImportHandler{
db: db,
proxyHostSvc: services.NewProxyHostService(db),
importerservice: caddy.NewImporter(caddyBinary),
importDir: importDir,
mountPath: mountPath,
}
}
// RegisterRoutes registers import-related routes.
func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/import/status", h.GetStatus)
router.GET("/import/preview", h.GetPreview)
router.POST("/import/upload", h.Upload)
router.POST("/import/upload-multi", h.UploadMulti)
router.POST("/import/detect-imports", h.DetectImports)
router.POST("/import/commit", h.Commit)
router.DELETE("/import/cancel", h.Cancel)
}
// GetStatus returns current import session status.
func (h *ImportHandler) GetStatus(c *gin.Context) {
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&session).Error
if err == gorm.ErrRecordNotFound {
// No pending/reviewing session, check if there's a mounted Caddyfile available for transient preview
if h.mountPath != "" {
if fileInfo, err := os.Stat(h.mountPath); err == nil {
// Check if this mount has already been committed recently
var committedSession models.ImportSession
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
Order("committed_at DESC").
First(&committedSession).Error
// Allow re-import if:
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
// 2. File was modified after last commit
allowImport := err == gorm.ErrRecordNotFound
if !allowImport && committedSession.CommittedAt != nil {
fileMod := fileInfo.ModTime()
commitTime := *committedSession.CommittedAt
allowImport = fileMod.After(commitTime)
}
if allowImport {
// Mount file is available for import
c.JSON(http.StatusOK, gin.H{
"has_pending": true,
"session": gin.H{
"id": "transient",
"state": "transient",
"source_file": h.mountPath,
},
})
return
}
// Mount file was already committed and hasn't been modified, don't offer it again
}
}
c.JSON(http.StatusOK, gin.H{"has_pending": false})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"has_pending": true,
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
},
})
}
// GetPreview returns parsed hosts and conflicts for review.
func (h *ImportHandler) GetPreview(c *gin.Context) {
var session models.ImportSession
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
Order("created_at DESC").
First(&session).Error
if err == nil {
// DB session found
var result caddy.ImportResult
if err := json.Unmarshal([]byte(session.ParsedData), &result); err == nil {
// Update status to reviewing
session.Status = "reviewing"
h.db.Save(&session)
// Read original Caddyfile content if available
var caddyfileContent string
if session.SourceFile != "" {
if content, err := os.ReadFile(session.SourceFile); err == nil {
caddyfileContent = string(content)
} else {
backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
if content, err := os.ReadFile(backupPath); err == nil {
caddyfileContent = string(content)
}
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{
"id": session.UUID,
"state": session.Status,
"created_at": session.CreatedAt,
"updated_at": session.UpdatedAt,
"source_file": session.SourceFile,
},
"preview": result,
"caddyfile_content": caddyfileContent,
})
return
}
}
// No DB session found or failed to parse session. Try transient preview from mountPath.
if h.mountPath != "" {
if fileInfo, err := os.Stat(h.mountPath); err == nil {
// Check if this mount has already been committed recently
var committedSession models.ImportSession
err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
Order("committed_at DESC").
First(&committedSession).Error
// Allow preview if:
// 1. Never committed before (err == gorm.ErrRecordNotFound), OR
// 2. File was modified after last commit
allowPreview := err == gorm.ErrRecordNotFound
if !allowPreview && committedSession.CommittedAt != nil {
allowPreview = fileInfo.ModTime().After(*committedSession.CommittedAt)
}
if !allowPreview {
// Mount file was already committed and hasn't been modified, don't offer preview again
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
return
}
// Parse mounted Caddyfile transiently
transient, err := h.importerservice.ImportFile(h.mountPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}
// Build a transient session id (not persisted)
sid := uuid.NewString()
var caddyfileContent string
if content, err := os.ReadFile(h.mountPath); err == nil {
caddyfileContent = string(content)
}
// Check for conflicts with existing hosts and build conflict details
existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts {
existingDomainsMap[eh.DomainNames] = eh
}
conflictDetails := make(map[string]gin.H)
for _, ph := range transient.Hosts {
if existing, found := existingDomainsMap[ph.DomainNames]; found {
transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
"preview": transient,
"caddyfile_content": caddyfileContent,
"conflict_details": conflictDetails,
})
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
}
// Upload handles manual Caddyfile upload or paste.
func (h *ImportHandler) Upload(c *gin.Context) {
var req struct {
Content string `json:"content" binding:"required"`
Filename string `json:"filename"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Save upload to import/uploads/<uuid>.caddyfile and return transient preview (do not persist yet)
sid := uuid.NewString()
uploadsDir, err := safeJoin(h.importDir, "uploads")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid import directory"})
return
}
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
return
}
tempPath, err := safeJoin(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid temp path"})
return
}
if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
return
}
// Parse uploaded file transiently
result, err := h.importerservice.ImportFile(tempPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return
}
// Check for conflicts with existing hosts and build conflict details
existingHosts, _ := h.proxyHostSvc.List()
existingDomainsMap := make(map[string]models.ProxyHost)
for _, eh := range existingHosts {
existingDomainsMap[eh.DomainNames] = eh
}
conflictDetails := make(map[string]gin.H)
for _, ph := range result.Hosts {
if existing, found := existingDomainsMap[ph.DomainNames]; found {
result.Conflicts = append(result.Conflicts, ph.DomainNames)
conflictDetails[ph.DomainNames] = gin.H{
"existing": gin.H{
"forward_scheme": existing.ForwardScheme,
"forward_host": existing.ForwardHost,
"forward_port": existing.ForwardPort,
"ssl_forced": existing.SSLForced,
"websocket": existing.WebsocketSupport,
"enabled": existing.Enabled,
},
"imported": gin.H{
"forward_scheme": ph.ForwardScheme,
"forward_host": ph.ForwardHost,
"forward_port": ph.ForwardPort,
"ssl_forced": ph.SSLForced,
"websocket": ph.WebsocketSupport,
},
}
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": tempPath},
"conflict_details": conflictDetails,
"preview": result,
})
}
// DetectImports analyzes Caddyfile content and returns detected import directives.
func (h *ImportHandler) DetectImports(c *gin.Context) {
var req struct {
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imports := detectImportDirectives(req.Content)
c.JSON(http.StatusOK, gin.H{
"has_imports": len(imports) > 0,
"imports": imports,
})
}
// UploadMulti handles upload of main Caddyfile + multiple site files.
func (h *ImportHandler) UploadMulti(c *gin.Context) {
var req struct {
Files []struct {
Filename string `json:"filename" binding:"required"`
Content string `json:"content" binding:"required"`
} `json:"files" binding:"required,min=1"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate: at least one file must be named "Caddyfile" or have no path separator
hasCaddyfile := false
for _, f := range req.Files {
if f.Filename == "Caddyfile" || !strings.Contains(f.Filename, "/") {
hasCaddyfile = true
break
}
}
if !hasCaddyfile {
c.JSON(http.StatusBadRequest, gin.H{"error": "must include a main Caddyfile"})
return
}
// Create session directory
sid := uuid.NewString()
sessionDir, err := safeJoin(h.importDir, filepath.Join("uploads", sid))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session directory"})
return
}
if err := os.MkdirAll(sessionDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"})
return
}
// Write all files
mainCaddyfile := ""
for _, f := range req.Files {
if strings.TrimSpace(f.Content) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file '%s' is empty", f.Filename)})
return
}
// Clean filename and create subdirectories if needed
cleanName := filepath.Clean(f.Filename)
targetPath, err := safeJoin(sessionDir, cleanName)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid filename: %s", f.Filename)})
return
}
// Create parent directory if file is in a subdirectory
if dir := filepath.Dir(targetPath); dir != sessionDir {
if err := os.MkdirAll(dir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create directory for %s", f.Filename)})
return
}
}
if err := os.WriteFile(targetPath, []byte(f.Content), 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s", f.Filename)})
return
}
// Track main Caddyfile
if cleanName == "Caddyfile" || !strings.Contains(cleanName, "/") {
mainCaddyfile = targetPath
}
}
// Parse the main Caddyfile (which will automatically resolve imports)
result, err := h.importerservice.ImportFile(mainCaddyfile)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
return
}
// Check for conflicts
existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, eh := range existingHosts {
existingDomains[eh.DomainNames] = true
}
for _, ph := range result.Hosts {
if existingDomains[ph.DomainNames] {
result.Conflicts = append(result.Conflicts, ph.DomainNames)
}
}
c.JSON(http.StatusOK, gin.H{
"session": gin.H{"id": sid, "state": "transient", "source_file": mainCaddyfile},
"preview": result,
})
}
// detectImportDirectives scans Caddyfile content for import directives.
func detectImportDirectives(content string) []string {
imports := []string{}
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "import ") {
path := strings.TrimSpace(strings.TrimPrefix(trimmed, "import"))
// Remove any trailing comments
if idx := strings.Index(path, "#"); idx != -1 {
path = strings.TrimSpace(path[:idx])
}
imports = append(imports, path)
}
}
return imports
}
// safeJoin joins a user-supplied path to a base directory and ensures
// the resulting path is contained within the base directory.
func safeJoin(baseDir, userPath string) (string, error) {
clean := filepath.Clean(userPath)
if clean == "" || clean == "." {
return "", fmt.Errorf("empty path not allowed")
}
if filepath.IsAbs(clean) {
return "", fmt.Errorf("absolute paths not allowed")
}
// Prevent attempts like ".." at start
if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || clean == ".." {
return "", fmt.Errorf("path traversal detected")
}
target := filepath.Join(baseDir, clean)
rel, err := filepath.Rel(baseDir, target)
if err != nil {
return "", fmt.Errorf("invalid path")
}
if strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("path traversal detected")
}
// Normalize to use base's separators
target = path.Clean(target)
return target, nil
}
// isSafePathUnderBase reports whether userPath, when cleaned and joined
// to baseDir, stays within baseDir. Used by tests.
func isSafePathUnderBase(baseDir, userPath string) bool {
_, err := safeJoin(baseDir, userPath)
return err == nil
}
// Commit finalizes the import with user's conflict resolutions.
func (h *ImportHandler) Commit(c *gin.Context) {
var req struct {
SessionUUID string `json:"session_uuid" binding:"required"`
Resolutions map[string]string `json:"resolutions"` // domain -> action (keep/skip, overwrite, rename)
Names map[string]string `json:"names"` // domain -> custom name
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Try to find a DB-backed session first
var session models.ImportSession
// Basic sanitize of session id to prevent path separators
sid := filepath.Base(req.SessionUUID)
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
return
}
var result *caddy.ImportResult
if err := h.db.Where("uuid = ? AND status = ?", sid, "reviewing").First(&session).Error; err == nil {
// DB session found
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
return
}
} else {
// No DB session: check for uploaded temp file
var parseErr error
uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
if err == nil {
if _, err := os.Stat(uploadsPath); err == nil {
r, err := h.importerservice.ImportFile(uploadsPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"})
return
}
result = r
// We'll create a committed DB session after applying
session = models.ImportSession{UUID: sid, SourceFile: uploadsPath}
}
}
// If not found yet, check mounted Caddyfile
if result == nil && h.mountPath != "" {
if _, err := os.Stat(h.mountPath); err == nil {
r, err := h.importerservice.ImportFile(h.mountPath)
if err != nil {
parseErr = err
} else {
result = r
session = models.ImportSession{UUID: sid, SourceFile: h.mountPath}
}
}
}
// If still not parsed, return not found or error
if result == nil {
if parseErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
return
}
}
// Convert parsed hosts to ProxyHost models
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
log.Printf("Import Commit: Parsed %d hosts, converted to %d proxy hosts", len(result.Hosts), len(proxyHosts))
created := 0
updated := 0
skipped := 0
errors := []string{}
// Get existing hosts to check for overwrites
existingHosts, _ := h.proxyHostSvc.List()
existingMap := make(map[string]*models.ProxyHost)
for i := range existingHosts {
existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
}
for _, host := range proxyHosts {
action := req.Resolutions[host.DomainNames]
// Apply custom name from user input
if customName, ok := req.Names[host.DomainNames]; ok && customName != "" {
host.Name = customName
}
// "keep" means keep existing (don't import), same as "skip"
if action == "skip" || action == "keep" {
skipped++
continue
}
if action == "rename" {
host.DomainNames += "-imported"
}
// Handle overwrite: preserve existing ID, UUID, and certificate
if action == "overwrite" {
if existing, found := existingMap[host.DomainNames]; found {
host.ID = existing.ID
host.UUID = existing.UUID
host.CertificateID = existing.CertificateID // Preserve certificate association
host.CreatedAt = existing.CreatedAt
if err := h.proxyHostSvc.Update(&host); err != nil {
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
errors = append(errors, errMsg)
log.Printf("Import Commit Error (update): %s", sanitizeForLog(errMsg))
} else {
updated++
log.Printf("Import Commit Success: Updated host %s", sanitizeForLog(host.DomainNames))
}
continue
}
// If "overwrite" but doesn't exist, fall through to create
}
// Create new host
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
errors = append(errors, errMsg)
log.Printf("Import Commit Error: %s", util.SanitizeForLog(errMsg))
} else {
created++
log.Printf("Import Commit Success: Created host %s", util.SanitizeForLog(host.DomainNames))
}
}
// Persist an import session record now that user confirmed
now := time.Now()
session.Status = "committed"
session.CommittedAt = &now
session.UserResolutions = string(mustMarshal(req.Resolutions))
// If ParsedData/ConflictReport not set, fill from result
if session.ParsedData == "" {
session.ParsedData = string(mustMarshal(result))
}
if session.ConflictReport == "" {
session.ConflictReport = string(mustMarshal(result.Conflicts))
}
if err := h.db.Save(&session).Error; err != nil {
log.Printf("Warning: failed to save import session: %v", err)
}
c.JSON(http.StatusOK, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}
// Cancel discards a pending import session.
func (h *ImportHandler) Cancel(c *gin.Context) {
sessionUUID := c.Query("session_uuid")
if sessionUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
return
}
sid := filepath.Base(sessionUUID)
if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
return
}
var session models.ImportSession
if err := h.db.Where("uuid = ?", sid).First(&session).Error; err == nil {
session.Status = "rejected"
h.db.Save(&session)
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
return
}
// If no DB session, check for uploaded temp file and delete it
uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
if err == nil {
if _, err := os.Stat(uploadsPath); err == nil {
os.Remove(uploadsPath)
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
return
}
}
// If neither exists, return not found
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
}
// CheckMountedImport checks for mounted Caddyfile on startup.
func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error {
if _, err := os.Stat(mountPath); os.IsNotExist(err) {
// If mount is gone, remove any pending/reviewing sessions created previously for this mount
db.Where("source_file = ? AND status IN ?", mountPath, []string{"pending", "reviewing"}).Delete(&models.ImportSession{})
return nil // No mounted file, nothing to import
}
// Check if already processed (includes committed to avoid re-imports)
var count int64
db.Model(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
if count > 0 {
return nil // Already processed
}
// Do not create a DB session automatically for mounted imports; preview will be transient.
return nil
}
func mustMarshal(v interface{}) []byte {
b, _ := json.Marshal(v)
return b
}
@@ -1,30 +0,0 @@
package handlers
import (
"path/filepath"
"testing"
)
func TestIsSafePathUnderBase(t *testing.T) {
base := filepath.FromSlash("/tmp/session")
cases := []struct{
name string
want bool
}{
{"Caddyfile", true},
{"site/site.conf", true},
{"../etc/passwd", false},
{"../../escape", false},
{"/absolute/path", false},
{"", false},
{".", false},
{"sub/../ok.txt", true},
}
for _, tc := range cases {
got := isSafePathUnderBase(base, tc.name)
if got != tc.want {
t.Fatalf("isSafePathUnderBase(%q, %q) = %v; want %v", base, tc.name, got, tc.want)
}
}
}
@@ -1,888 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/handlers"
"github.com/Wikid82/charon/backend/internal/models"
)
func setupImportTestDB(t *testing.T) *gorm.DB {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
db.AutoMigrate(&models.ImportSession{}, &models.ProxyHost{}, &models.Location{})
return db
}
func TestImportHandler_GetStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Case 1: No active session, no mount
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.GET("/import/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, false, resp["has_pending"])
// Case 2: No DB session but has mounted Caddyfile
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
os.WriteFile(mountPath, []byte("example.com"), 0644)
handler2 := handlers.NewImportHandler(db, "echo", "/tmp", mountPath)
router2 := gin.New()
router2.GET("/import/status", handler2.GetStatus)
w = httptest.NewRecorder()
router2.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, true, resp["has_pending"])
session := resp["session"].(map[string]interface{})
assert.Equal(t, "transient", session["state"])
assert.Equal(t, mountPath, session["source_file"])
// Case 3: Active DB session (takes precedence over mount)
dbSession := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": []}`,
}
db.Create(&dbSession)
w = httptest.NewRecorder()
router2.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, true, resp["has_pending"])
session = resp["session"].(map[string]interface{})
assert.Equal(t, "pending", session["state"]) // DB session, not transient
}
func TestImportHandler_GetPreview(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
// Case 1: No session
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Case 2: Active session
session := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": [{"domain_names": "example.com"}]}`,
}
db.Create(&session)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &result)
preview := result["preview"].(map[string]interface{})
hosts := preview["hosts"].([]interface{})
assert.Len(t, hosts, 1)
// Verify status changed to reviewing
var updatedSession models.ImportSession
db.First(&updatedSession, session.ID)
assert.Equal(t, "reviewing", updatedSession.Status)
}
func TestImportHandler_Cancel(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.DELETE("/import/cancel", handler.Cancel)
session := models.ImportSession{
UUID: "test-uuid",
Status: "pending",
}
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=test-uuid", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updatedSession models.ImportSession
db.First(&updatedSession, session.ID)
assert.Equal(t, "rejected", updatedSession.Status)
}
func TestImportHandler_Commit(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/commit", handler.Commit)
session := models.ImportSession{
UUID: "test-uuid",
Status: "reviewing",
ParsedData: `{"hosts": [{"domain_names": "example.com", "forward_host": "127.0.0.1", "forward_port": 8080}]}`,
}
db.Create(&session)
payload := map[string]interface{}{
"session_uuid": "test-uuid",
"resolutions": map[string]string{
"example.com": "import",
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify host created
var host models.ProxyHost
err := db.Where("domain_names = ?", "example.com").First(&host).Error
assert.NoError(t, err)
assert.Equal(t, "127.0.0.1", host.ForwardHost)
// Verify session committed
var updatedSession models.ImportSession
db.First(&updatedSession, session.ID)
assert.Equal(t, "committed", updatedSession.Status)
}
func TestImportHandler_Upload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
os.Chmod(fakeCaddy, 0755)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
"content": "example.com",
"filename": "Caddyfile",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
// The fake caddy script returns empty JSON, so import might fail or succeed with empty result
// But Upload calls ImportFile which calls ParseCaddyfile which calls caddy adapt
// fake_caddy.sh echoes `{"apps":{}}`
// ExtractHosts will return empty result
// Upload should succeed
assert.Equal(t, http.StatusOK, w.Code)
}
func TestImportHandler_GetPreview_WithContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
// Case: Active session with source file
content := "example.com {\n reverse_proxy localhost:8080\n}"
sourceFile := filepath.Join(tmpDir, "source.caddyfile")
err := os.WriteFile(sourceFile, []byte(content), 0644)
assert.NoError(t, err)
// Case: Active session with source file
session := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": []}`,
SourceFile: sourceFile,
}
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
assert.Equal(t, content, result["caddyfile_content"])
}
func TestImportHandler_Commit_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/commit", handler.Commit)
// Case 1: Invalid JSON
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBufferString("invalid"))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Case 2: Session not found
payload := map[string]interface{}{
"session_uuid": "non-existent",
"resolutions": map[string]string{},
}
body, _ := json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Case 3: Invalid ParsedData
session := models.ImportSession{
UUID: "invalid-data-uuid",
Status: "reviewing",
ParsedData: "invalid-json",
}
db.Create(&session)
payload = map[string]interface{}{
"session_uuid": "invalid-data-uuid",
"resolutions": map[string]string{},
}
body, _ = json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestImportHandler_Cancel_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.DELETE("/import/cancel", handler.Cancel)
// Case 1: Session not found
w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestCheckMountedImport(t *testing.T) {
db := setupImportTestDB(t)
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
os.Chmod(fakeCaddy, 0755)
// Case 1: File does not exist
err := handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
// Case 2: File exists, not processed
err = os.WriteFile(mountPath, []byte("example.com"), 0644)
assert.NoError(t, err)
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
// Check if session created (transient preview behavior: no DB session should be created)
var count int64
db.Model(&models.ImportSession{}).Where("source_file = ?", mountPath).Count(&count)
assert.Equal(t, int64(0), count)
// Case 3: Already processed
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
assert.NoError(t, err)
}
func TestImportHandler_Upload_Failure(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Use fake caddy script that fails
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_fail.sh")
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
"content": "invalid caddyfile",
"filename": "Caddyfile",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// The error message comes from Upload -> ImportFile -> "import failed: ..."
assert.Contains(t, resp["error"], "import failed")
}
func TestImportHandler_Upload_Conflict(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
// Pre-create a host to cause conflict
db.Create(&models.ProxyHost{
DomainNames: "example.com",
ForwardHost: "127.0.0.1",
ForwardPort: 9090,
})
// Use fake caddy script that returns hosts
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
payload := map[string]string{
"content": "example.com",
"filename": "Caddyfile",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify response contains conflict in preview (upload is transient)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
preview := resp["preview"].(map[string]interface{})
conflicts := preview["conflicts"].([]interface{})
found := false
for _, c := range conflicts {
if c.(string) == "example.com" || strings.Contains(c.(string), "example.com") {
found = true
break
}
}
assert.True(t, found, "expected conflict for example.com in preview")
}
func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
// Create backup file
backupDir := filepath.Join(tmpDir, "backups")
os.MkdirAll(backupDir, 0755)
content := "backup content"
backupFile := filepath.Join(backupDir, "source.caddyfile")
os.WriteFile(backupFile, []byte(content), 0644)
// Case: Active session with missing source file but existing backup
session := models.ImportSession{
UUID: uuid.NewString(),
Status: "pending",
ParsedData: `{"hosts": []}`,
SourceFile: "/non/existent/source.caddyfile",
}
db.Create(&session)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &result)
assert.Equal(t, content, result["caddyfile_content"])
}
func TestImportHandler_RegisterRoutes(t *testing.T) {
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
api := router.Group("/api/v1")
handler.RegisterRoutes(api)
// Verify routes exist by making requests
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/import/status", nil)
router.ServeHTTP(w, req)
assert.NotEqual(t, http.StatusNotFound, w.Code)
}
func TestImportHandler_GetPreview_TransientMount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
// Create a mounted Caddyfile
content := "example.com"
err := os.WriteFile(mountPath, []byte(content), 0644)
assert.NoError(t, err)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
router.GET("/import/preview", handler.GetPreview)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/import/preview", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Response body: %s", w.Body.String())
var result map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &result)
assert.NoError(t, err)
// Verify transient session
session, ok := result["session"].(map[string]interface{})
assert.True(t, ok, "session should be present in response")
assert.Equal(t, "transient", session["state"])
assert.Equal(t, mountPath, session["source_file"])
// Verify preview contains hosts
preview, ok := result["preview"].(map[string]interface{})
assert.True(t, ok, "preview should be present in response")
assert.NotNil(t, preview["hosts"])
// Verify content
assert.Equal(t, content, result["caddyfile_content"])
}
func TestImportHandler_Commit_TransientUpload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
router.POST("/import/commit", handler.Commit)
// First upload to create transient session
uploadPayload := map[string]string{
"content": "uploaded.com",
"filename": "Caddyfile",
}
uploadBody, _ := json.Marshal(uploadPayload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(uploadBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Extract session ID
var uploadResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &uploadResp)
session := uploadResp["session"].(map[string]interface{})
sessionID := session["id"].(string)
// Now commit the transient upload
commitPayload := map[string]interface{}{
"session_uuid": sessionID,
"resolutions": map[string]string{
"uploaded.com": "import",
},
}
commitBody, _ := json.Marshal(commitPayload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(commitBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify host created
var host models.ProxyHost
err := db.Where("domain_names = ?", "uploaded.com").First(&host).Error
assert.NoError(t, err)
assert.Equal(t, "uploaded.com", host.DomainNames)
// Verify session persisted
var importSession models.ImportSession
err = db.Where("uuid = ?", sessionID).First(&importSession).Error
assert.NoError(t, err)
assert.Equal(t, "committed", importSession.Status)
}
func TestImportHandler_Commit_TransientMount(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
// Create a mounted Caddyfile
err := os.WriteFile(mountPath, []byte("mounted.com"), 0644)
assert.NoError(t, err)
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
router := gin.New()
router.POST("/import/commit", handler.Commit)
// Commit the mount with a random session ID (transient)
sessionID := uuid.NewString()
commitPayload := map[string]interface{}{
"session_uuid": sessionID,
"resolutions": map[string]string{
"mounted.com": "import",
},
}
commitBody, _ := json.Marshal(commitPayload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(commitBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify host created
var host models.ProxyHost
err = db.Where("domain_names = ?", "mounted.com").First(&host).Error
assert.NoError(t, err)
// Verify session persisted
var importSession models.ImportSession
err = db.Where("uuid = ?", sessionID).First(&importSession).Error
assert.NoError(t, err)
assert.Equal(t, "committed", importSession.Status)
}
func TestImportHandler_Cancel_TransientUpload(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
router.DELETE("/import/cancel", handler.Cancel)
// Upload to create transient file
uploadPayload := map[string]string{
"content": "test.com",
"filename": "Caddyfile",
}
uploadBody, _ := json.Marshal(uploadPayload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(uploadBody))
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Extract session ID and file path
var uploadResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &uploadResp)
session := uploadResp["session"].(map[string]interface{})
sessionID := session["id"].(string)
sourceFile := session["source_file"].(string)
// Verify file exists
_, err := os.Stat(sourceFile)
assert.NoError(t, err)
// Cancel should delete the file
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid="+sessionID, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify file deleted
_, err = os.Stat(sourceFile)
assert.True(t, os.IsNotExist(err))
}
func TestImportHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/upload", handler.Upload)
router.POST("/import/commit", handler.Commit)
router.DELETE("/import/cancel", handler.Cancel)
// Upload - Invalid JSON
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Commit - Invalid JSON
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Commit - Session Not Found
body := map[string]interface{}{
"session_uuid": "non-existent",
"resolutions": map[string]string{},
}
jsonBody, _ := json.Marshal(body)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Cancel - Session Not Found
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestImportHandler_DetectImports(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
router := gin.New()
router.POST("/import/detect-imports", handler.DetectImports)
tests := []struct {
name string
content string
hasImport bool
imports []string
}{
{
name: "no imports",
content: "example.com { reverse_proxy localhost:8080 }",
hasImport: false,
imports: []string{},
},
{
name: "single import",
content: "import sites/*\nexample.com { reverse_proxy localhost:8080 }",
hasImport: true,
imports: []string{"sites/*"},
},
{
name: "multiple imports",
content: "import sites/*\nimport config/ssl.conf\nexample.com { reverse_proxy localhost:8080 }",
hasImport: true,
imports: []string{"sites/*", "config/ssl.conf"},
},
{
name: "import with comment",
content: "import sites/* # Load all sites\nexample.com { reverse_proxy localhost:8080 }",
hasImport: true,
imports: []string{"sites/*"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := map[string]string{"content": tt.content}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/detect-imports", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)
assert.Equal(t, tt.hasImport, resp["has_imports"])
imports := resp["imports"].([]interface{})
assert.Len(t, imports, len(tt.imports))
})
}
}
func TestImportHandler_UploadMulti(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupImportTestDB(t)
tmpDir := t.TempDir()
// Use fake caddy script
cwd, _ := os.Getwd()
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
os.Chmod(fakeCaddy, 0755)
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
router := gin.New()
router.POST("/import/upload-multi", handler.UploadMulti)
t.Run("single Caddyfile", func(t *testing.T) {
payload := map[string]interface{}{
"files": []map[string]string{
{"filename": "Caddyfile", "content": "example.com"},
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.NotNil(t, resp["session"])
assert.NotNil(t, resp["preview"])
})
t.Run("Caddyfile with site files", func(t *testing.T) {
payload := map[string]interface{}{
"files": []map[string]string{
{"filename": "Caddyfile", "content": "import sites/*\n"},
{"filename": "sites/site1", "content": "site1.com"},
{"filename": "sites/site2", "content": "site2.com"},
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
session := resp["session"].(map[string]interface{})
assert.Equal(t, "transient", session["state"])
})
t.Run("missing Caddyfile", func(t *testing.T) {
payload := map[string]interface{}{
"files": []map[string]string{
{"filename": "sites/site1", "content": "site1.com"},
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("path traversal in filename", func(t *testing.T) {
payload := map[string]interface{}{
"files": []map[string]string{
{"filename": "Caddyfile", "content": "import sites/*\n"},
{"filename": "../etc/passwd", "content": "sensitive"},
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("empty file content", func(t *testing.T) {
payload := map[string]interface{}{
"files": []map[string]string{
{"filename": "Caddyfile", "content": "example.com"},
{"filename": "sites/site1", "content": " "},
},
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Contains(t, resp["error"], "empty")
})
}
@@ -1,106 +0,0 @@
package handlers
import (
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type LogsHandler struct {
service *services.LogService
}
func NewLogsHandler(service *services.LogService) *LogsHandler {
return &LogsHandler{service: service}
}
func (h *LogsHandler) List(c *gin.Context) {
logs, err := h.service.ListLogs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list logs"})
return
}
c.JSON(http.StatusOK, logs)
}
func (h *LogsHandler) Read(c *gin.Context) {
filename := c.Param("filename")
// Parse query parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
filter := models.LogFilter{
Search: c.Query("search"),
Host: c.Query("host"),
Status: c.Query("status"),
Level: c.Query("level"),
Limit: limit,
Offset: offset,
Sort: c.DefaultQuery("sort", "desc"),
}
logs, total, err := h.service.QueryLogs(filename, filter)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read log"})
return
}
c.JSON(http.StatusOK, gin.H{
"filename": filename,
"logs": logs,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *LogsHandler) Download(c *gin.Context) {
filename := c.Param("filename")
path, err := h.service.GetLogPath(filename)
if err != nil {
if strings.Contains(err.Error(), "invalid filename") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
return
}
// 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("", "charon-log-*.log")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
return
}
defer os.Remove(tmpFile.Name())
srcFile, err := os.Open(path)
if err != nil {
_ = tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open log file"})
return
}
defer func() { _ = srcFile.Close() }()
if _, err := io.Copy(tmpFile, srcFile); err != nil {
_ = tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy log file"})
return
}
_ = tmpFile.Close()
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(tmpFile.Name())
}
@@ -1,161 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/services"
)
func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) {
t.Helper()
// Create temp directories
tmpDir, err := os.MkdirTemp("", "cpm-logs-test")
require.NoError(t, err)
// LogService expects LogDir to be .../data/logs
// It derives it from cfg.DatabasePath
dataDir := filepath.Join(tmpDir, "data")
err = os.MkdirAll(dataDir, 0755)
require.NoError(t, err)
dbPath := filepath.Join(dataDir, "charon.db")
// Create logs dir
logsDir := filepath.Join(dataDir, "logs")
err = os.MkdirAll(logsDir, 0755)
require.NoError(t, err)
// Create dummy log files with JSON content
log1 := `{"level":"info","ts":1600000000,"msg":"request handled","request":{"method":"GET","host":"example.com","uri":"/","remote_ip":"1.2.3.4"},"status":200}`
log2 := `{"level":"error","ts":1600000060,"msg":"error handled","request":{"method":"POST","host":"api.example.com","uri":"/submit","remote_ip":"5.6.7.8"},"status":500}`
err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(log1+"\n"+log2+"\n"), 0644)
require.NoError(t, err)
// Write a charon.log and create a cpmp.log symlink to it for backward compatibility (cpmp is legacy)
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 (cpmp is a legacy name for Charon)
_ = os.Symlink(filepath.Join(logsDir, "charon.log"), filepath.Join(logsDir, "cpmp.log"))
require.NoError(t, err)
cfg := &config.Config{
DatabasePath: dbPath,
}
svc := services.NewLogService(cfg)
h := NewLogsHandler(svc)
r := gin.New()
api := r.Group("/api/v1")
logs := api.Group("/logs")
logs.GET("", h.List)
logs.GET("/:filename", h.Read)
logs.GET("/:filename/download", h.Download)
return r, svc, tmpDir
}
func TestLogsLifecycle(t *testing.T) {
router, _, tmpDir := setupLogsTest(t)
defer os.RemoveAll(tmpDir)
// 1. List logs
req := httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var logs []services.LogFile
err := json.Unmarshal(resp.Body.Bytes(), &logs)
require.NoError(t, err)
require.Len(t, logs, 2) // access.log and cpmp.log
// Verify content of one log file
found := false
for _, l := range logs {
if l.Name == "access.log" {
found = true
require.Greater(t, l.Size, int64(0))
}
}
require.True(t, found)
// 2. Read log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log?limit=2", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var content struct {
Filename string `json:"filename"`
Logs []interface{} `json:"logs"`
Total int `json:"total"`
}
err = json.Unmarshal(resp.Body.Bytes(), &content)
require.NoError(t, err)
require.Len(t, content.Logs, 2)
// 3. Download log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
require.Contains(t, resp.Body.String(), "request handled")
// 4. Read non-existent log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 5. Download non-existent log
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log/download", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// 6. List logs error (delete directory)
os.RemoveAll(filepath.Join(tmpDir, "data", "logs"))
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// ListLogs returns empty list if dir doesn't exist, so it should be 200 OK with empty list
require.Equal(t, http.StatusOK, resp.Code)
var emptyLogs []services.LogFile
err = json.Unmarshal(resp.Body.Bytes(), &emptyLogs)
require.NoError(t, err)
require.Empty(t, emptyLogs)
}
func TestLogsHandler_PathTraversal(t *testing.T) {
_, _, tmpDir := setupLogsTest(t)
defer os.RemoveAll(tmpDir)
// Manually invoke handler to bypass Gin router cleaning
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "filename", Value: "../access.log"}}
cfg := &config.Config{
DatabasePath: filepath.Join(tmpDir, "data", "charon.db"),
}
svc := services.NewLogService(cfg)
h := NewLogsHandler(svc)
h.Download(c)
require.Equal(t, http.StatusBadRequest, w.Code)
require.Contains(t, w.Body.String(), "invalid filename")
}
@@ -1,43 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationHandler struct {
service *services.NotificationService
}
func NewNotificationHandler(service *services.NotificationService) *NotificationHandler {
return &NotificationHandler{service: service}
}
func (h *NotificationHandler) List(c *gin.Context) {
unreadOnly := c.Query("unread") == "true"
notifications, err := h.service.List(unreadOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
return
}
c.JSON(http.StatusOK, notifications)
}
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
id := c.Param("id")
if err := h.service.MarkAsRead(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"})
}
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
if err := h.service.MarkAllAsRead(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all notifications as read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"})
}
@@ -1,148 +0,0 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"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 {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
db.AutoMigrate(&models.Notification{})
return db
}
func TestNotificationHandler_List(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
// Seed data
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: true})
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.GET("/notifications", handler.List)
// Test List All
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/notifications", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var notifications []models.Notification
err := json.Unmarshal(w.Body.Bytes(), &notifications)
assert.NoError(t, err)
assert.Len(t, notifications, 2)
// Test List Unread
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notifications?unread=true", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
err = json.Unmarshal(w.Body.Bytes(), &notifications)
assert.NoError(t, err)
assert.Len(t, notifications, 1)
assert.False(t, notifications[0].Read)
}
func TestNotificationHandler_MarkAsRead(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
// Seed data
notif := &models.Notification{Title: "Test 1", Message: "Msg 1", Read: false}
db.Create(notif)
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.POST("/notifications/:id/read", handler.MarkAsRead)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notifications/"+notif.ID+"/read", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated models.Notification
db.First(&updated, "id = ?", notif.ID)
assert.True(t, updated.Read)
}
func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
// Seed data
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: false})
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
router := gin.New()
router.POST("/notifications/read-all", handler.MarkAllAsRead)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var count int64
db.Model(&models.Notification{}).Where("read = ?", false).Count(&count)
assert.Equal(t, int64(0), count)
}
func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
r := gin.New()
r.POST("/notifications/read-all", handler.MarkAllAsRead)
// Close DB to force error
sqlDB, _ := db.DB()
sqlDB.Close()
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestNotificationHandler_DBError(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupNotificationTestDB()
service := services.NewNotificationService(db)
handler := handlers.NewNotificationHandler(service)
r := gin.New()
r.POST("/notifications/:id/read", handler.MarkAsRead)
// Close DB to force error
sqlDB, _ := db.DB()
sqlDB.Close()
req, _ := http.NewRequest("POST", "/notifications/1/read", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
@@ -1,143 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationProviderHandler struct {
service *services.NotificationService
}
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
return &NotificationProviderHandler{service: service}
}
func (h *NotificationProviderHandler) List(c *gin.Context) {
providers, err := h.service.ListProviders()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
return
}
c.JSON(http.StatusOK, providers)
}
func (h *NotificationProviderHandler) Create(c *gin.Context) {
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.CreateProvider(&provider); err != nil {
// If it's a validation error from template parsing, return 400
if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
return
}
c.JSON(http.StatusCreated, provider)
}
func (h *NotificationProviderHandler) Update(c *gin.Context) {
id := c.Param("id")
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
provider.ID = id
if err := h.service.UpdateProvider(&provider); err != nil {
if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
return
}
c.JSON(http.StatusOK, provider)
}
func (h *NotificationProviderHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteProvider(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Provider deleted"})
}
func (h *NotificationProviderHandler) Test(c *gin.Context) {
var provider models.NotificationProvider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.TestProvider(provider); err != nil {
// Create internal notification for the failure
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// Templates returns a list of built-in templates a provider can use.
func (h *NotificationProviderHandler) Templates(c *gin.Context) {
c.JSON(http.StatusOK, []gin.H{
{"id": "minimal", "name": "Minimal", "description": "Small JSON payload with title, message and time."},
{"id": "detailed", "name": "Detailed", "description": "Full JSON payload with host, services and all data."},
{"id": "custom", "name": "Custom", "description": "Use your own JSON template in the Config field."},
})
}
// Preview renders the template for a provider and returns the resulting JSON object or an error.
func (h *NotificationProviderHandler) Preview(c *gin.Context) {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var provider models.NotificationProvider
// Marshal raw into provider to get proper types
if b, err := json.Marshal(raw); err == nil {
_ = json.Unmarshal(b, &provider)
}
var payload map[string]interface{}
if d, ok := raw["data"].(map[string]interface{}); ok {
payload = d
}
if payload == nil {
payload = map[string]interface{}{}
}
// Add some defaults for preview
if _, ok := payload["Title"]; !ok {
payload["Title"] = "Preview Title"
}
if _, ok := payload["Message"]; !ok {
payload["Message"] = "Preview Message"
}
payload["Time"] = time.Now().Format(time.RFC3339)
payload["EventType"] = "preview"
rendered, parsed, err := h.service.RenderTemplate(provider, payload)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
return
}
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
}
@@ -1,231 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"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/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) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
service := services.NewNotificationService(db)
handler := handlers.NewNotificationProviderHandler(service)
r := gin.Default()
api := r.Group("/api/v1")
providers := api.Group("/notifications/providers")
providers.GET("", handler.List)
providers.POST("/preview", handler.Preview)
providers.POST("", handler.Create)
providers.PUT("/:id", handler.Update)
providers.DELETE("/:id", handler.Delete)
providers.POST("/test", handler.Test)
api.GET("/notifications/templates", handler.Templates)
return r, db
}
func TestNotificationProviderHandler_CRUD(t *testing.T) {
r, db := setupNotificationProviderTest(t)
// 1. Create
provider := models.NotificationProvider{
Name: "Test Discord",
Type: "discord",
URL: "https://discord.com/api/webhooks/...",
}
body, _ := json.Marshal(provider)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var created models.NotificationProvider
err := json.Unmarshal(w.Body.Bytes(), &created)
require.NoError(t, err)
assert.Equal(t, provider.Name, created.Name)
assert.NotEmpty(t, created.ID)
// 2. List
req, _ = http.NewRequest("GET", "/api/v1/notifications/providers", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var list []models.NotificationProvider
err = json.Unmarshal(w.Body.Bytes(), &list)
require.NoError(t, err)
assert.Len(t, list, 1)
// 3. Update
created.Name = "Updated Discord"
body, _ = json.Marshal(created)
req, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/"+created.ID, bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var updated models.NotificationProvider
err = json.Unmarshal(w.Body.Bytes(), &updated)
require.NoError(t, err)
assert.Equal(t, "Updated Discord", updated.Name)
// Verify in DB
var dbProvider models.NotificationProvider
db.First(&dbProvider, "id = ?", created.ID)
assert.Equal(t, "Updated Discord", dbProvider.Name)
// 4. Delete
req, _ = http.NewRequest("DELETE", "/api/v1/notifications/providers/"+created.ID, nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify Delete
var count int64
db.Model(&models.NotificationProvider{}).Count(&count)
assert.Equal(t, int64(0), count)
}
func TestNotificationProviderHandler_Templates(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
req, _ := http.NewRequest("GET", "/api/v1/notifications/templates", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var templates []map[string]string
err := json.Unmarshal(w.Body.Bytes(), &templates)
require.NoError(t, err)
assert.Len(t, templates, 3)
}
func TestNotificationProviderHandler_Test(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
// Test with invalid provider (should fail validation or service check)
// Since we don't have a real shoutrrr backend mocked easily here without more work,
// we expect it might fail or pass depending on service implementation.
// Looking at service code (not shown but assumed), TestProvider likely calls shoutrrr.Send.
// If URL is invalid, it should error.
provider := models.NotificationProvider{
Type: "discord",
URL: "invalid-url",
}
body, _ := json.Marshal(provider)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// It should probably fail with 400
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestNotificationProviderHandler_Errors(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
// Create Invalid JSON
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer([]byte("invalid")))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Update Invalid JSON
req, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/123", bytes.NewBuffer([]byte("invalid")))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Test Invalid JSON
req, _ = http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer([]byte("invalid")))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestNotificationProviderHandler_InvalidCustomTemplate_Rejects(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
// Create with invalid custom template should return 400
provider := models.NotificationProvider{
Name: "Bad",
Type: "webhook",
URL: "http://example.com",
Template: "custom",
Config: `{"broken": "{{.Title"}`,
}
body, _ := json.Marshal(provider)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Create valid and then attempt update to invalid custom template
provider = models.NotificationProvider{
Name: "Good",
Type: "webhook",
URL: "http://example.com",
Template: "minimal",
}
body, _ = json.Marshal(provider)
req, _ = http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var created models.NotificationProvider
_ = json.Unmarshal(w.Body.Bytes(), &created)
created.Template = "custom"
created.Config = `{"broken": "{{.Title"}`
body, _ = json.Marshal(created)
req, _ = http.NewRequest("PUT", "/api/v1/notifications/providers/"+created.ID, bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestNotificationProviderHandler_Preview(t *testing.T) {
r, _ := setupNotificationProviderTest(t)
// Minimal template preview
provider := models.NotificationProvider{
Type: "webhook",
URL: "http://example.com",
Template: "minimal",
}
body, _ := json.Marshal(provider)
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/preview", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Contains(t, resp, "rendered")
assert.Contains(t, resp, "parsed")
// Invalid template should not succeed
provider.Config = `{"broken": "{{.Title"}`
provider.Template = "custom"
body, _ = json.Marshal(provider)
req, _ = http.NewRequest("POST", "/api/v1/notifications/providers/preview", bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
@@ -1,97 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type NotificationTemplateHandler struct {
service *services.NotificationService
}
func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler {
return &NotificationTemplateHandler{service: s}
}
func (h *NotificationTemplateHandler) List(c *gin.Context) {
list, err := h.service.ListTemplates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"})
return
}
c.JSON(http.StatusOK, list)
}
func (h *NotificationTemplateHandler) Create(c *gin.Context) {
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.CreateTemplate(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"})
return
}
c.JSON(http.StatusCreated, t)
}
func (h *NotificationTemplateHandler) Update(c *gin.Context) {
id := c.Param("id")
var t models.NotificationTemplate
if err := c.ShouldBindJSON(&t); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
t.ID = id
if err := h.service.UpdateTemplate(&t); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
return
}
c.JSON(http.StatusOK, t)
}
func (h *NotificationTemplateHandler) Delete(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteTemplate(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// Preview allows rendering an arbitrary template (provided in request) or a stored template by id.
func (h *NotificationTemplateHandler) Preview(c *gin.Context) {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var tmplStr string
if id, ok := raw["template_id"].(string); ok && id != "" {
t, err := h.service.GetTemplate(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"})
return
}
tmplStr = t.Config
} else if s, ok := raw["template"].(string); ok {
tmplStr = s
}
data := map[string]interface{}{}
if d, ok := raw["data"].(map[string]interface{}); ok {
data = d
}
// Build a fake provider to leverage existing RenderTemplate logic
provider := models.NotificationProvider{Template: "custom", Config: tmplStr}
rendered, parsed, err := h.service.RenderTemplate(provider, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
return
}
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
}
@@ -1,52 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"io"
"testing"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.NotificationTemplate{})
return db
}
func TestNotificationTemplateCRUD(t *testing.T) {
db := setupDB(t)
svc := services.NewNotificationService(db)
h := NewNotificationTemplateHandler(svc)
// Create
payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}`
req := httptest.NewRequest("POST", "/", nil)
req.Body = io.NopCloser(strings.NewReader(payload))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
h.Create(c)
require.Equal(t, http.StatusCreated, w.Code)
// List
req2 := httptest.NewRequest("GET", "/", nil)
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Request = req2
h.List(c2)
require.Equal(t, http.StatusOK, w2.Code)
var list []models.NotificationTemplate
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list))
require.Len(t, list, 1)
}
@@ -1,324 +0,0 @@
package handlers
import (
"fmt"
"log"
"net/http"
"strconv"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"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.
type ProxyHostHandler struct {
service *services.ProxyHostService
caddyManager *caddy.Manager
notificationService *services.NotificationService
}
// NewProxyHostHandler creates a new proxy host handler.
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService) *ProxyHostHandler {
return &ProxyHostHandler{
service: services.NewProxyHostService(db),
caddyManager: caddyManager,
notificationService: ns,
}
}
// RegisterRoutes registers proxy host routes.
func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/proxy-hosts", h.List)
router.POST("/proxy-hosts", h.Create)
router.GET("/proxy-hosts/:uuid", h.Get)
router.PUT("/proxy-hosts/:uuid", h.Update)
router.DELETE("/proxy-hosts/:uuid", h.Delete)
router.POST("/proxy-hosts/test", h.TestConnection)
router.PUT("/proxy-hosts/bulk-update-acl", h.BulkUpdateACL)
}
// List retrieves all proxy hosts.
func (h *ProxyHostHandler) List(c *gin.Context) {
hosts, err := h.service.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, hosts)
}
// Create creates a new proxy host.
func (h *ProxyHostHandler) Create(c *gin.Context) {
var host models.ProxyHost
if err := c.ShouldBindJSON(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate and normalize advanced config if present
if host.AdvancedConfig != "" {
var parsed interface{}
if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
return
}
parsed = caddy.NormalizeAdvancedConfig(parsed)
if norm, err := json.Marshal(parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
return
} else {
host.AdvancedConfig = string(norm)
}
}
host.UUID = uuid.NewString()
// Assign UUIDs to locations
for i := range host.Locations {
host.Locations[i].UUID = uuid.NewString()
}
if err := h.service.Create(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
// Rollback: delete the created host if config application fails
log.Printf("Error applying config: %s", sanitizeForLog(err.Error()))
if deleteErr := h.service.Delete(host.ID); deleteErr != nil {
idStr := strconv.FormatUint(uint64(host.ID), 10)
log.Printf("Critical: Failed to rollback host %s: %s", sanitizeForLog(idStr), sanitizeForLog(deleteErr.Error()))
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"proxy_host",
"Proxy Host Created",
fmt.Sprintf("Proxy Host %s (%s) created", host.Name, host.DomainNames),
map[string]interface{}{
"Name": host.Name,
"Domains": host.DomainNames,
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, host)
}
// Get retrieves a proxy host by UUID.
func (h *ProxyHostHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
c.JSON(http.StatusOK, host)
}
// Update updates an existing proxy host.
func (h *ProxyHostHandler) Update(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
var incoming models.ProxyHost
if err := c.ShouldBindJSON(&incoming); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate and normalize advanced config if present and changed
if incoming.AdvancedConfig != "" && incoming.AdvancedConfig != host.AdvancedConfig {
var parsed interface{}
if err := json.Unmarshal([]byte(incoming.AdvancedConfig), &parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
return
}
parsed = caddy.NormalizeAdvancedConfig(parsed)
if norm, err := json.Marshal(parsed); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
return
} else {
incoming.AdvancedConfig = string(norm)
}
}
// 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()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
c.JSON(http.StatusOK, host)
}
// Delete removes a proxy host.
func (h *ProxyHostHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
host, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
return
}
if err := h.service.Delete(host.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
return
}
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"proxy_host",
"Proxy Host Deleted",
fmt.Sprintf("Proxy Host %s deleted", host.Name),
map[string]interface{}{
"Name": host.Name,
"Action": "deleted",
},
)
}
c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"})
}
// TestConnection checks if the proxy host is reachable.
func (h *ProxyHostHandler) TestConnection(c *gin.Context) {
var req struct {
ForwardHost string `json:"forward_host" binding:"required"`
ForwardPort int `json:"forward_port" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.TestConnection(req.ForwardHost, req.ForwardPort); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Connection successful"})
}
// BulkUpdateACL applies or removes an access list to multiple proxy hosts.
func (h *ProxyHostHandler) BulkUpdateACL(c *gin.Context) {
var req struct {
HostUUIDs []string `json:"host_uuids" binding:"required"`
AccessListID *uint `json:"access_list_id"` // nil means remove ACL
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.HostUUIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "host_uuids cannot be empty"})
return
}
updated := 0
errors := []map[string]string{}
for _, uuid := range req.HostUUIDs {
host, err := h.service.GetByUUID(uuid)
if err != nil {
errors = append(errors, map[string]string{
"uuid": uuid,
"error": "proxy host not found",
})
continue
}
host.AccessListID = req.AccessListID
if err := h.service.Update(host); err != nil {
errors = append(errors, map[string]string{
"uuid": uuid,
"error": err.Error(),
})
continue
}
updated++
}
// Apply Caddy config once for all updates
if updated > 0 && h.caddyManager != nil {
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to apply configuration: " + err.Error(),
"updated": updated,
"errors": errors,
})
return
}
}
c.JSON(http.StatusOK, gin.H{
"updated": updated,
"errors": errors,
})
}
@@ -1,519 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"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) {
t.Helper()
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}))
ns := services.NewNotificationService(db)
h := NewProxyHostHandler(db, nil, ns)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
return r, db
}
func TestProxyHostLifecycle(t *testing.T) {
router, _ := setupTestRouter(t)
body := `{"name":"Media","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":true}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
var created models.ProxyHost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
require.Equal(t, "media.example.com", created.DomainNames)
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
listResp := httptest.NewRecorder()
router.ServeHTTP(listResp, listReq)
require.Equal(t, http.StatusOK, listResp.Code)
var hosts []models.ProxyHost
require.NoError(t, json.Unmarshal(listResp.Body.Bytes(), &hosts))
require.Len(t, hosts, 1)
// Get by ID
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
getResp := httptest.NewRecorder()
router.ServeHTTP(getResp, getReq)
require.Equal(t, http.StatusOK, getResp.Code)
var fetched models.ProxyHost
require.NoError(t, json.Unmarshal(getResp.Body.Bytes(), &fetched))
require.Equal(t, created.UUID, fetched.UUID)
// Update
updateBody := `{"name":"Media Updated","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":false}`
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+created.UUID, strings.NewReader(updateBody))
updateReq.Header.Set("Content-Type", "application/json")
updateResp := httptest.NewRecorder()
router.ServeHTTP(updateResp, updateReq)
require.Equal(t, http.StatusOK, updateResp.Code)
var updated models.ProxyHost
require.NoError(t, json.Unmarshal(updateResp.Body.Bytes(), &updated))
require.Equal(t, "Media Updated", updated.Name)
require.False(t, updated.Enabled)
// Delete
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+created.UUID, nil)
delResp := httptest.NewRecorder()
router.ServeHTTP(delResp, delReq)
require.Equal(t, http.StatusOK, delResp.Code)
// Verify Delete
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
getResp2 := httptest.NewRecorder()
router.ServeHTTP(getResp2, getReq2)
require.Equal(t, http.StatusNotFound, getResp2.Code)
}
func TestProxyHostErrors(t *testing.T) {
// Mock Caddy Admin API that fails
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer caddyServer.Close()
// Setup DB
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}))
// Setup Caddy Manager
tmpDir := t.TempDir()
client := caddy.NewClient(caddyServer.URL)
manager := caddy.NewManager(client, db, tmpDir, "", false)
// Setup Handler
ns := services.NewNotificationService(db)
h := NewProxyHostHandler(db, manager, ns)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
// Test Create - Bind Error
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`invalid json`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Test Create - Apply Config Error
body := `{"name":"Fail Host","domain_names":"fail-unique-456.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
// Create a host for Update/Delete/Get tests (manually in DB to avoid handler error)
host := models.ProxyHost{
UUID: uuid.NewString(),
Name: "Existing Host",
DomainNames: "exist.local",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 8080,
Enabled: true,
}
db.Create(&host)
// Test Get - Not Found
req = httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Test Update - Not Found
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/non-existent-uuid", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Test Update - Bind Error
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(`invalid json`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Test Update - Apply Config Error
updateBody := `{"name":"Fail Host Update","domain_names":"fail-unique-update.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
// Test Delete - Not Found
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusNotFound, resp.Code)
// Test Delete - Apply Config Error
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID, nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
// Test TestConnection - Bind Error
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(`invalid json`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Test TestConnection - Connection Failure
testBody := `{"forward_host": "invalid.host.local", "forward_port": 12345}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(testBody))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadGateway, resp.Code)
}
func TestProxyHostValidation(t *testing.T) {
router, db := setupTestRouter(t)
// Invalid JSON
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`{invalid json}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// Create a host first
host := &models.ProxyHost{
UUID: "valid-uuid",
DomainNames: "valid.com",
}
db.Create(host)
// Update with invalid JSON
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/valid-uuid", strings.NewReader(`{invalid json}`))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
func TestProxyHostConnection(t *testing.T) {
router, _ := setupTestRouter(t)
// 1. Test Invalid Input (Missing Host)
body := `{"forward_port": 80}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
// 2. Test Connection Failure (Unreachable Port)
// Use a reserved port or localhost port that is likely closed
body = `{"forward_host": "localhost", "forward_port": 54321}`
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
// It should return 502 Bad Gateway
require.Equal(t, http.StatusBadGateway, resp.Code)
// 3. Test Connection Success
// Start a local listener
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer l.Close()
addr := l.Addr().(*net.TCPAddr)
body = fmt.Sprintf(`{"forward_host": "%s", "forward_port": %d}`, addr.IP.String(), addr.Port)
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
func TestProxyHostHandler_List_Error(t *testing.T) {
router, db := setupTestRouter(t)
// Close DB to force error
sqlDB, _ := db.DB()
sqlDB.Close()
req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusInternalServerError, resp.Code)
}
func TestProxyHostWithCaddyIntegration(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 == "POST" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer caddyServer.Close()
// Setup DB
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}))
// Setup Caddy Manager
tmpDir := t.TempDir()
client := caddy.NewClient(caddyServer.URL)
manager := caddy.NewManager(client, db, tmpDir, "", false)
// Setup Handler
ns := services.NewNotificationService(db)
h := NewProxyHostHandler(db, manager, ns)
r := gin.New()
api := r.Group("/api/v1")
h.RegisterRoutes(api)
// Test Create with Caddy Sync
body := `{"name":"Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusCreated, resp.Code)
// Test Update with Caddy Sync
var createdHost models.ProxyHost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &createdHost))
updateBody := `{"name":"Updated Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8081,"enabled":true}`
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+createdHost.UUID, strings.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
// Test Delete with Caddy Sync
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+createdHost.UUID, nil)
resp = httptest.NewRecorder()
r.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
}
func TestProxyHostHandler_BulkUpdateACL_Success(t *testing.T) {
router, db := setupTestRouter(t)
// Create an access list
acl := &models.AccessList{
Name: "Test ACL",
Type: "ip",
Enabled: true,
}
require.NoError(t, db.Create(acl).Error)
// Create multiple proxy hosts
host1 := &models.ProxyHost{
UUID: uuid.NewString(),
Name: "Host 1",
DomainNames: "host1.example.com",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 8001,
Enabled: true,
}
host2 := &models.ProxyHost{
UUID: uuid.NewString(),
Name: "Host 2",
DomainNames: "host2.example.com",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 8002,
Enabled: true,
}
require.NoError(t, db.Create(host1).Error)
require.NoError(t, db.Create(host2).Error)
// Apply ACL to both hosts
body := fmt.Sprintf(`{"host_uuids":["%s","%s"],"access_list_id":%d}`, host1.UUID, host2.UUID, acl.ID)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-acl", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
require.Equal(t, float64(2), result["updated"])
require.Empty(t, result["errors"])
// Verify hosts have ACL assigned
var updatedHost1 models.ProxyHost
require.NoError(t, db.First(&updatedHost1, "uuid = ?", host1.UUID).Error)
require.NotNil(t, updatedHost1.AccessListID)
require.Equal(t, acl.ID, *updatedHost1.AccessListID)
var updatedHost2 models.ProxyHost
require.NoError(t, db.First(&updatedHost2, "uuid = ?", host2.UUID).Error)
require.NotNil(t, updatedHost2.AccessListID)
require.Equal(t, acl.ID, *updatedHost2.AccessListID)
}
func TestProxyHostHandler_BulkUpdateACL_RemoveACL(t *testing.T) {
router, db := setupTestRouter(t)
// Create an access list
acl := &models.AccessList{
Name: "Test ACL",
Type: "ip",
Enabled: true,
}
require.NoError(t, db.Create(acl).Error)
// Create proxy host with ACL
host := &models.ProxyHost{
UUID: uuid.NewString(),
Name: "Host with ACL",
DomainNames: "acl-host.example.com",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 8000,
AccessListID: &acl.ID,
Enabled: true,
}
require.NoError(t, db.Create(host).Error)
// Remove ACL (access_list_id: null)
body := fmt.Sprintf(`{"host_uuids":["%s"],"access_list_id":null}`, host.UUID)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-acl", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
require.Equal(t, float64(1), result["updated"])
require.Empty(t, result["errors"])
// Verify ACL removed
var updatedHost models.ProxyHost
require.NoError(t, db.First(&updatedHost, "uuid = ?", host.UUID).Error)
require.Nil(t, updatedHost.AccessListID)
}
func TestProxyHostHandler_BulkUpdateACL_PartialFailure(t *testing.T) {
router, db := setupTestRouter(t)
// Create an access list
acl := &models.AccessList{
Name: "Test ACL",
Type: "ip",
Enabled: true,
}
require.NoError(t, db.Create(acl).Error)
// Create one valid host
host := &models.ProxyHost{
UUID: uuid.NewString(),
Name: "Valid Host",
DomainNames: "valid.example.com",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 8000,
Enabled: true,
}
require.NoError(t, db.Create(host).Error)
// Try to update valid host + non-existent host
nonExistentUUID := uuid.NewString()
body := fmt.Sprintf(`{"host_uuids":["%s","%s"],"access_list_id":%d}`, host.UUID, nonExistentUUID, acl.ID)
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-acl", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusOK, resp.Code)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
require.Equal(t, float64(1), result["updated"])
errors := result["errors"].([]interface{})
require.Len(t, errors, 1)
errorMap := errors[0].(map[string]interface{})
require.Equal(t, nonExistentUUID, errorMap["uuid"])
require.Equal(t, "proxy host not found", errorMap["error"])
// Verify valid host was updated
var updatedHost models.ProxyHost
require.NoError(t, db.First(&updatedHost, "uuid = ?", host.UUID).Error)
require.NotNil(t, updatedHost.AccessListID)
require.Equal(t, acl.ID, *updatedHost.AccessListID)
}
func TestProxyHostHandler_BulkUpdateACL_EmptyUUIDs(t *testing.T) {
router, _ := setupTestRouter(t)
body := `{"host_uuids":[],"access_list_id":1}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-acl", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
require.Contains(t, result["error"], "host_uuids cannot be empty")
}
func TestProxyHostHandler_BulkUpdateACL_InvalidJSON(t *testing.T) {
router, _ := setupTestRouter(t)
body := `{"host_uuids": invalid json}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/bulk-update-acl", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
require.Equal(t, http.StatusBadRequest, resp.Code)
}
@@ -1,237 +0,0 @@
package handlers
import (
"fmt"
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
)
// RemoteServerHandler handles HTTP requests for remote server management.
type RemoteServerHandler struct {
service *services.RemoteServerService
notificationService *services.NotificationService
}
// NewRemoteServerHandler creates a new remote server handler.
func NewRemoteServerHandler(service *services.RemoteServerService, ns *services.NotificationService) *RemoteServerHandler {
return &RemoteServerHandler{
service: service,
notificationService: ns,
}
}
// RegisterRoutes registers remote server routes.
func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/remote-servers", h.List)
router.POST("/remote-servers", h.Create)
router.GET("/remote-servers/:uuid", h.Get)
router.PUT("/remote-servers/:uuid", h.Update)
router.DELETE("/remote-servers/:uuid", h.Delete)
router.POST("/remote-servers/test", h.TestConnectionCustom)
router.POST("/remote-servers/:uuid/test", h.TestConnection)
}
// List retrieves all remote servers.
func (h *RemoteServerHandler) List(c *gin.Context) {
enabledOnly := c.Query("enabled") == "true"
servers, err := h.service.List(enabledOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, servers)
}
// Create creates a new remote server.
func (h *RemoteServerHandler) Create(c *gin.Context) {
var server models.RemoteServer
if err := c.ShouldBindJSON(&server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
server.UUID = uuid.NewString()
if err := h.service.Create(&server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"remote_server",
"Remote Server Added",
fmt.Sprintf("Remote Server %s (%s:%d) added", server.Name, server.Host, server.Port),
map[string]interface{}{
"Name": server.Name,
"Host": server.Host,
"Port": server.Port,
"Action": "created",
},
)
}
c.JSON(http.StatusCreated, server)
}
// Get retrieves a remote server by UUID.
func (h *RemoteServerHandler) Get(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
c.JSON(http.StatusOK, server)
}
// Update updates an existing remote server.
func (h *RemoteServerHandler) Update(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := c.ShouldBindJSON(server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.Update(server); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, server)
}
// Delete removes a remote server.
func (h *RemoteServerHandler) Delete(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := h.service.Delete(server.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Send Notification
if h.notificationService != nil {
h.notificationService.SendExternal(
"remote_server",
"Remote Server Deleted",
fmt.Sprintf("Remote Server %s deleted", server.Name),
map[string]interface{}{
"Name": server.Name,
"Action": "deleted",
},
)
}
c.JSON(http.StatusNoContent, nil)
}
// TestConnection tests the TCP connection to a remote server.
func (h *RemoteServerHandler) TestConnection(c *gin.Context) {
uuid := c.Param("uuid")
server, err := h.service.GetByUUID(uuid)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
// Test TCP connection with 5 second timeout
address := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port))
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
result := gin.H{
"server_uuid": server.UUID,
"address": address,
"timestamp": time.Now().UTC(),
}
if err != nil {
result["reachable"] = false
result["error"] = err.Error()
// Update server reachability status
server.Reachable = false
now := time.Now().UTC()
server.LastChecked = &now
_ = h.service.Update(server)
c.JSON(http.StatusOK, result)
return
}
defer func() { _ = conn.Close() }()
// Connection successful
result["reachable"] = true
result["latency_ms"] = time.Since(time.Now()).Milliseconds()
// Update server reachability status
server.Reachable = true
now := time.Now().UTC()
server.LastChecked = &now
_ = h.service.Update(server)
c.JSON(http.StatusOK, result)
}
// TestConnectionCustom tests connectivity to a host/port provided in the body
func (h *RemoteServerHandler) TestConnectionCustom(c *gin.Context) {
var req struct {
Host string `json:"host" binding:"required"`
Port int `json:"port" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Test TCP connection with 5 second timeout
address := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
start := time.Now()
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
result := gin.H{
"address": address,
"timestamp": time.Now().UTC(),
}
if err != nil {
result["reachable"] = false
result["error"] = err.Error()
c.JSON(http.StatusOK, result)
return
}
defer func() { _ = conn.Close() }()
// Connection successful
result["reachable"] = true
result["latency_ms"] = time.Since(start).Milliseconds()
c.JSON(http.StatusOK, result)
}
@@ -1,129 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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) {
t.Helper()
db := setupTestDB()
// Ensure RemoteServer table exists
db.AutoMigrate(&models.RemoteServer{})
ns := services.NewNotificationService(db)
handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
r := gin.Default()
api := r.Group("/api/v1")
servers := api.Group("/remote-servers")
servers.GET("", handler.List)
servers.POST("", handler.Create)
servers.GET("/:uuid", handler.Get)
servers.PUT("/:uuid", handler.Update)
servers.DELETE("/:uuid", handler.Delete)
servers.POST("/test", handler.TestConnectionCustom)
servers.POST("/:uuid/test", handler.TestConnection)
return r, handler
}
func TestRemoteServerHandler_TestConnectionCustom(t *testing.T) {
r, _ := setupRemoteServerTest_New(t)
// Test with a likely closed port
payload := map[string]interface{}{
"host": "127.0.0.1",
"port": 54321,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/test", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &result)
require.NoError(t, err)
assert.Equal(t, false, result["reachable"])
assert.NotEmpty(t, result["error"])
}
func TestRemoteServerHandler_FullCRUD(t *testing.T) {
r, _ := setupRemoteServerTest_New(t)
// Create
rs := models.RemoteServer{
Name: "Test Server CRUD",
Host: "192.168.1.100",
Port: 22,
Provider: "manual",
}
body, _ := json.Marshal(rs)
req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var created models.RemoteServer
err := json.Unmarshal(w.Body.Bytes(), &created)
require.NoError(t, err)
assert.Equal(t, rs.Name, created.Name)
assert.NotEmpty(t, created.UUID)
// List
req, _ = http.NewRequest("GET", "/api/v1/remote-servers", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Get
req, _ = http.NewRequest("GET", "/api/v1/remote-servers/"+created.UUID, nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Update
created.Name = "Updated Server CRUD"
body, _ = json.Marshal(created)
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/"+created.UUID, bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Delete
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/"+created.UUID, nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
// Create - Invalid JSON
req, _ = http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer([]byte("invalid json")))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Update - Not Found
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/non-existent-uuid", bytes.NewBuffer(body))
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
// Delete - Not Found
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent-uuid", nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
-20
View File
@@ -1,20 +0,0 @@
package handlers
import (
"regexp"
"strings"
)
// sanitizeForLog removes control characters and newlines from user content before logging.
func sanitizeForLog(s string) string {
if s == "" {
return s
}
// Replace CRLF and LF with spaces and remove other control chars
s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\n", " ")
// remove any other non-printable control characters
re := regexp.MustCompile(`[\x00-\x1F\x7F]+`)
s = re.ReplaceAllString(s, " ")
return s
}
@@ -1,24 +0,0 @@
package handlers
import (
"testing"
)
func TestSanitizeForLog(t *testing.T) {
cases := []struct{
in string
want string
}{
{"normal text", "normal text"},
{"line\nbreak", "line break"},
{"carriage\rreturn\nline", "carriage return line"},
{"control\x00chars", "control chars"},
}
for _, tc := range cases {
got := sanitizeForLog(tc.in)
if got != tc.want {
t.Fatalf("sanitizeForLog(%q) = %q; want %q", tc.in, got, tc.want)
}
}
}
@@ -1,65 +0,0 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"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, 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,
"enabled": h.cfg.CrowdSecMode != "disabled",
},
"waf": gin.H{
"mode": h.cfg.WAFMode,
"enabled": h.cfg.WAFMode == "enabled",
},
"rate_limit": gin.H{
"mode": h.cfg.RateLimitMode,
"enabled": h.cfg.RateLimitMode == "enabled",
},
"acl": gin.H{
"mode": h.cfg.ACLMode,
"enabled": h.cfg.ACLMode == "enabled",
},
})
}
@@ -1,82 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
)
func setupTestDB(t *testing.T) *gorm.DB {
// lightweight in-memory DB unique per test run
dsn := fmt.Sprintf("file:security_handler_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open DB: %v", err)
}
if err := db.AutoMigrate(&models.Setting{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
return db
}
func TestSecurityHandler_GetStatus_Clean(t *testing.T) {
gin.SetMode(gin.TestMode)
// Basic disabled scenario
cfg := config.SecurityConfig{
CrowdSecMode: "disabled",
WAFMode: "disabled",
RateLimitMode: "disabled",
ACLMode: "disabled",
}
handler := NewSecurityHandler(cfg, nil)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.NotNil(t, response["cerberus"])
}
func TestSecurityHandler_Cerberus_DBOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
// set DB to enable cerberus
if err := db.Create(&models.Setting{Key: "security.cerberus.enabled", Value: "true"}).Error; err != nil {
t.Fatalf("failed to insert setting: %v", err)
}
cfg := config.SecurityConfig{CerberusEnabled: false}
handler := NewSecurityHandler(cfg, db)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
cerb := response["cerberus"].(map[string]interface{})
assert.Equal(t, true, cerb["enabled"].(bool))
}
@@ -1,888 +0,0 @@
//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 (
"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{}
}{
{
expectedBody: map[string]interface{}{
"cerberus": map[string]interface{}{"enabled": false},
cfg: config.SecurityConfig{
CrowdSecMode: "disabled",
WAFMode: "disabled",
RateLimitMode: "disabled",
ACLMode: "disabled",
},
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"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{}{
"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{}{
handler := NewSecurityHandler(tt.cfg, nil)
"enabled": true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewSecurityHandler(tt.cfg)
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)
})
}
}
@@ -1,111 +0,0 @@
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)
})
}
}
@@ -1,71 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
type SettingsHandler struct {
DB *gorm.DB
}
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
return &SettingsHandler{DB: db}
}
// GetSettings returns all settings.
func (h *SettingsHandler) GetSettings(c *gin.Context) {
var settings []models.Setting
if err := h.DB.Find(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
}
// Convert to map for easier frontend consumption
settingsMap := make(map[string]string)
for _, s := range settings {
settingsMap[s.Key] = s.Value
}
c.JSON(http.StatusOK, settingsMap)
}
type UpdateSettingRequest struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
Category string `json:"category"`
Type string `json:"type"`
}
// UpdateSetting updates or creates a setting.
func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
var req UpdateSettingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
setting := models.Setting{
Key: req.Key,
Value: req.Value,
}
if req.Category != "" {
setting.Category = req.Category
}
if req.Type != "" {
setting.Type = req.Type
}
// Upsert
if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"})
return
}
c.JSON(http.StatusOK, setting)
}
@@ -1,121 +0,0 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/api/handlers"
"github.com/Wikid82/charon/backend/internal/models"
)
func setupSettingsTestDB(t *testing.T) *gorm.DB {
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect to test database")
}
db.AutoMigrate(&models.Setting{})
return db
}
func TestSettingsHandler_GetSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
// Seed data
db.Create(&models.Setting{Key: "test_key", Value: "test_value", Category: "general", Type: "string"})
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router.GET("/settings", handler.GetSettings)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/settings", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]string
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "test_value", response["test_key"])
}
func TestSettingsHandler_UpdateSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router.POST("/settings", handler.UpdateSetting)
// Test Create
payload := map[string]string{
"key": "new_key",
"value": "new_value",
"category": "system",
"type": "string",
}
body, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var setting models.Setting
db.Where("key = ?", "new_key").First(&setting)
assert.Equal(t, "new_value", setting.Value)
// Test Update
payload["value"] = "updated_value"
body, _ = json.Marshal(payload)
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
db.Where("key = ?", "new_key").First(&setting)
assert.Equal(t, "updated_value", setting.Value)
}
func TestSettingsHandler_Errors(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupSettingsTestDB(t)
handler := handlers.NewSettingsHandler(db)
router := gin.New()
router.POST("/settings", handler.UpdateSetting)
// Invalid JSON
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer([]byte("invalid")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
// Missing Key/Value
payload := map[string]string{
"key": "some_key",
// value missing
}
body, _ := json.Marshal(payload)
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
@@ -1,74 +0,0 @@
package handlers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type SystemHandler struct{}
func NewSystemHandler() *SystemHandler {
return &SystemHandler{}
}
type MyIPResponse struct {
IP string `json:"ip"`
Source string `json:"source"`
}
// GetMyIP returns the client's public IP address
func (h *SystemHandler) GetMyIP(c *gin.Context) {
// Try to get the real IP from various headers (in order of preference)
// This handles proxies, load balancers, and CDNs
ip := getClientIP(c.Request)
source := "direct"
if c.GetHeader("X-Forwarded-For") != "" {
source = "X-Forwarded-For"
} else if c.GetHeader("X-Real-IP") != "" {
source = "X-Real-IP"
} else if c.GetHeader("CF-Connecting-IP") != "" {
source = "Cloudflare"
}
c.JSON(http.StatusOK, MyIPResponse{
IP: ip,
Source: source,
})
}
// getClientIP extracts the real client IP from the request
// Checks headers in order of trust/reliability
func getClientIP(r *http.Request) string {
// Cloudflare
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
// Other CDNs/proxies
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
// Standard proxy header (can be a comma-separated list)
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
// Take the first IP in the list (client IP)
ips := strings.Split(forwarded, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Fallback to RemoteAddr (format: "IP:port")
if ip := r.RemoteAddr; ip != "" {
// Remove port if present
if idx := strings.LastIndex(ip, ":"); idx != -1 {
return ip[:idx]
}
return ip
}
return "unknown"
}
-2
View File
@@ -1,2 +0,0 @@
#!/bin/sh
echo '{"apps":{}}'
@@ -1,6 +0,0 @@
#!/bin/sh
if [ "$1" = "version" ]; then
echo "v2.0.0"
exit 0
fi
exit 1
@@ -1,15 +0,0 @@
#!/bin/sh
if [ "$1" = "version" ]; then
echo "v2.0.0"
exit 0
fi
if [ "$1" = "adapt" ]; then
# Read the domain from the input Caddyfile (stdin or --config file)
DOMAIN="example.com"
if [ "$2" = "--config" ]; then
DOMAIN=$(cat "$3" | head -1 | tr -d '\n')
fi
echo "{\"apps\":{\"http\":{\"servers\":{\"srv0\":{\"routes\":[{\"match\":[{\"host\":[\"$DOMAIN\"]}],\"handle\":[{\"handler\":\"reverse_proxy\",\"upstreams\":[{\"dial\":\"localhost:8080\"}]}]}]}}}}}"
exit 0
fi
exit 1
@@ -1,25 +0,0 @@
package handlers
import (
"net/http"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
type UpdateHandler struct {
service *services.UpdateService
}
func NewUpdateHandler(service *services.UpdateService) *UpdateHandler {
return &UpdateHandler{service: service}
}
func (h *UpdateHandler) Check(c *gin.Context) {
info, err := h.service.CheckForUpdates()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for updates"})
return
}
c.JSON(http.StatusOK, info)
}

Some files were not shown because too many files have changed in this diff Show More