Compare commits

..

653 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] ea6af50a9d Initial plan 2025-11-29 22:23:36 +00:00
CI f62606bb58 Merge PR #260: take PR changes for conflicted files 2025-11-29 21:38:48 +00:00
CI a60be34f60 chore(ci): add PR-only Trivy app-only scan and pin Caddy v2.10.2 2025-11-29 21:23:54 +00:00
CI e030b139d5 feat: add Trivy scan for pull requests and revert Caddy version to 2.10.2 2025-11-29 21:21:17 +00:00
CI 72dc73f453 build(ci): attempt to override Caddy deps (expr, quic-go) in module cache before xcaddy build 2025-11-29 21:11:35 +00:00
CI 0d27e1e188 build(ci): try requested Caddy v2 tag then fallback to v2.10.2 2025-11-29 21:10:25 +00:00
CI 910e338d30 chore: pin Caddy major to v2 and document SSRF mitigations in notification service 2025-11-29 21:08:52 +00:00
CI 5cea5755a0 feat: add external notification templates management
- Introduced NotificationTemplate model for reusable external notification templates.
- Implemented CRUD operations for external templates in NotificationService.
- Added routes for managing external templates in the API.
- Created frontend API methods for external templates.
- Enhanced Notifications page to manage external templates with a form and list view.
- Updated layout and login pages to improve UI consistency.
- Added integration tests for proxy host management with improved error handling.
2025-11-29 20:51:46 +00:00
CI 82dad8d9cb docs(notification): document SSRF mitigations for custom webhooks 2025-11-29 17:40:40 +00:00
CI bbe9ca3698 fix(notification): mitigate SSRF for webhook requests by resolving host to IP and using Host header 2025-11-29 17:38:40 +00:00
CI 78f7f7ecad chore(ci): CodeQL setup + seed + integration test (cherry-pick)
(cherry picked from commit 35957bf196c6aee928d66e723f3074c2b2935d68)
2025-11-29 17:11:17 +00:00
CI 143c0dfe43 chore(ci): CodeQL + seed + integration-test improvements 2025-11-29 17:09:51 +00:00
CI fdab765cbd chore: update internal files for consistency and maintainability 2025-11-29 15:52:27 +00:00
CI 66bd5313c1 feat(notification): enhance webhook template handling with minimal and detailed options 2025-11-29 09:15:03 +00:00
Wikid82 66e37fbd69 Add new logo image to public assets 2025-11-29 04:08:56 -05:00
CI fcc273262c test(caddy): cover invalid path branches; ci: handle go test non-zero when coverage file exists 2025-11-29 08:55:25 +00:00
CI 0c62118989 chore: ignore CodeQL DBs and analysis artifacts 2025-11-29 04:24:30 +00:00
CI 46eb444c97 test: tighten URL checks by parsing href host instead of substring checks 2025-11-29 03:24:31 +00:00
CI 7e5e3e4d38 chore: update go.work format by removing parentheses around backend usage 2025-11-29 02:49:21 +00:00
CI c925994aa3 feat: add Codecov upload workflows for backend and frontend coverage 2025-11-29 02:49:21 +00:00
CI 55199c752a chore: configure gopls env/buildFlags to avoid scanning toolchain modules 2025-11-29 02:49:21 +00:00
CI e4854763f4 chore: add gopls buildFlags for ignore-tagged tests 2025-11-29 02:49:21 +00:00
CI ce8a51e6c7 fix(workflows): replace invalid semantic-version action with fallback script 2025-11-29 02:49:21 +00:00
CI ebd8a8e92b chore: make scripts/check-version-match-tag.sh executable for pre-commit 2025-11-29 02:47:31 +00:00
Wikid82 62bbd6693e fix: reorder feature list in README for improved clarity 2025-11-28 15:50:12 -05:00
Wikid82 83668da3d8 fix: reorder features in README for better clarity 2025-11-28 15:48:30 -05:00
Wikid82 fc1bf92bd6 fix: add spacing before license and release badges in README 2025-11-28 15:43:46 -05:00
Wikid82 1a2568ff4d fix: correct header level for Cerberus section in README 2025-11-28 15:42:25 -05:00
Wikid82 d3d3bd9a22 fix: correct header level for Cerberus section in README 2025-11-28 15:40:06 -05:00
Wikid82 19f884f88c fix: update README to enhance clarity and detail about Charon and Cerberus 2025-11-28 15:37:49 -05:00
Wikid82 938692bbdc fix: update README to enhance project descriptions for Charon and Cerberus 2025-11-28 15:36:52 -05:00
Wikid82 f426595013 fix: update README to reflect project name change and improve clarity 2025-11-28 15:18:44 -05:00
Wikid82 9269a802dd fix: update README to reflect project name change from Caddy Proxy Manager+ to Charon 2025-11-28 15:16:33 -05:00
Wikid82 39210aee8c Refactor code structure for improved readability and maintainability 2025-11-28 12:02:25 -05:00
Wikid82 de39f5d7a5 fix: restore Charon.code-workspace configuration file 2025-11-28 11:59:21 -05:00
Wikid82 d96ff80f57 fix: add missing checkout step in test-image job
The test-image job was trying to run ./scripts/integration-test.sh
without first checking out the repository, causing CI failures.
2025-11-28 13:52:19 +00:00
Wikid82 2d68bc2d2d feat: improve bulk ACL modal with multi-select, progress indicator, and Select All/Clear
- Added checkboxes to select multiple ACLs at once
- Added Select All / Clear buttons for quick selection
- Added progress indicator when applying multiple ACLs
- ACLs are applied sequentially with visual feedback
- All tests passing with 81.32% coverage
2025-11-28 07:22:30 +00:00
Wikid82 0118f28fa7 fix: convert all internal doc links from .md to .html 2025-11-28 06:38:18 +00:00
Wikid82 1807d65c4c fix: use .html extensions in docs index for GitHub Pages 2025-11-28 06:31:00 +00:00
Wikid82 69bc7ff48d docs: add instruction for updating docs.yml index cards 2025-11-28 06:28:03 +00:00
Wikid82 d2f0226679 fix: resolve TypeScript any types and unused variable warnings 2025-11-28 06:27:00 +00:00
Wikid82 f37b20949a docs: add debugging and github-setup guides to landing page 2025-11-28 06:26:48 +00:00
Wikid82 31ab1f8976 fix: correct GitHub Pages documentation URLs and add features to docs index 2025-11-28 06:17:31 +00:00
Wikid82 c123fe52fd docs: update documentation link format to use GitHub Pages URLs 2025-11-28 06:00:37 +00:00
Wikid82 d31b613759 docs: add GitHub Pages link format to copilot instructions 2025-11-28 06:00:16 +00:00
Wikid82 3ca3067556 fix: update documentation links to point to the correct repository 2025-11-28 05:58:42 +00:00
Wikid82 9d5daff52c docs: update links to use GitHub Pages URLs 2025-11-28 05:58:07 +00:00
Wikid82 74d7bf2ac3 feat: enhance Access List management with delete confirmation and backup functionality 2025-11-28 05:52:17 +00:00
Wikid82 0273da0841 feat: add new public assets and update pre-commit configuration for large files 2025-11-28 05:47:42 +00:00
Wikid82 38b7df0c27 docs: add features.md, restructure docs, simplify README as marketing page 2025-11-28 05:42:29 +00:00
Wikid82 7a37b2f480 docs: add features.md, restructure docs, simplify README as marketing page 2025-11-28 05:41:55 +00:00
Wikid82 d5d4caf9b4 Refactor pre-commit hooks to manual execution, enhance uptime monitoring with new models and batching notifications
- Updated .pre-commit-config.yaml to change hooks to manual execution for performance.
- Added UptimeHost and UptimeNotificationEvent models to support host-level monitoring.
- Enhanced UptimeService to batch notifications for multiple service failures on the same host.
- Implemented tests for notification batching and uptime host creation during sync.
- Improved formatDuration function for better readability of durations.
2025-11-28 04:00:36 +00:00
Wikid82 a4cff3c194 feat: Add security presets and related tests
- Implemented new security presets for access control lists, including geo-blacklist and known botnet IPs.
- Added tests for security presets functionality, including validation of preset structure and category/type checks.
- Created hooks for Docker and domains with comprehensive tests for fetching, creating, and deleting domains.
- Removed unused HealthStatus component.
- Updated ProxyHosts bulk delete tests to reflect changes in selection logic.
- Introduced integration test script for automated testing of proxy host creation and validation.
2025-11-28 02:54:44 +00:00
Wikid82 72fd121bdb fix: resolve race conditions and update golangci-lint config
- Fix TestCertificateHandler_Delete race condition:
  - Add WAL mode and busy_timeout to SQLite connection
  - Add sleep to allow background sync goroutine to complete
- Fix TestNotificationService_SendExternal_EdgeCases race condition:
  - Use atomic.Value for cross-goroutine string access
- Update .golangci.yml for version 2:
  - Add version field
  - Move linters-settings under linters.settings
  - Remove deprecated typecheck and gosimple linters
  - Update govet shadow check syntax
2025-11-28 00:54:47 +00:00
Wikid82 1a9c651efd feat: add windows and macos support to goreleaser
- Update .goreleaser.yaml:
  - Split builds into linux, windows, and darwin
  - Configure Zig as the C/C++ compiler for cross-compilation (CGO enabled)
  - Add zip archive format for Windows
  - Restrict deb/rpm packages to Linux builds
- Update .github/workflows/release-goreleaser.yml:
  - Add setup-zig action to install Zig compiler
  - Remove manual apt-get cross-compiler installation
2025-11-28 00:34:25 +00:00
Wikid82 ca4ddc4e3e feat: enhance dev workflow and prepare for goreleaser
- Update Makefile with new targets:
  - lint-backend: Run golangci-lint via Docker
  - lint-docker: Run hadolint via Docker
  - test-race: Run Go tests with race detection
  - benchmark: Run Go benchmarks
  - integration-test: Run local integration tests
- Update .pre-commit-config.yaml:
  - Add go-test-race hook
  - Add golangci-lint hook
  - Add hadolint hook
- Add .goreleaser.yaml configuration:
  - Define builds for linux/amd64 and linux/arm64
  - Configure archive creation (tar.gz)
  - Configure package creation (deb, rpm)
- Add .github/workflows/release-goreleaser.yml:
  - New workflow to test GoReleaser builds
  - Builds frontend first, then uses GoReleaser
  - Handles cross-compilation dependencies
2025-11-28 00:28:42 +00:00
Wikid82 fc27b5c42e feat: add security presets and block list recommendations for ACLs
- Add security preset system with curated threat intelligence
  - High-Risk Countries preset (RU, CN, KP, IR, etc.) ~800M IPs
  - Expanded Threat List preset ~1.2B IPs
  - Cloud Scanner IPs preset (Shodan, Censys) ~3K IPs
  - Tor Exit Nodes preset ~1.2K IPs (changes daily)
- Add tooltips linking to data sources (SANS ISC, Spamhaus, Tor Project)
- Add 'Get My IP' button to quickly add current IP to allowlist
- Add IP range calculator showing total IPs covered by rules
- Emphasize block lists over allow lists in UI
  - Renamed UI labels to show 'Recommended' for block lists
  - Added info box explaining why block lists are safer
- Add /system/my-ip API endpoint to fetch user's public IP
  - Handles X-Forwarded-For, X-Real-IP, CF-Connecting-IP headers
  - Returns IP and source (direct, proxy, Cloudflare, etc.)
- Add ARIA attributes to ProxyHosts checkboxes for accessibility

Block lists prevent lockouts while maintaining security by blocking
known threats instead of requiring explicit allow lists that can
inadvertently block legitimate users (especially CGNAT/mobile users).

Note: Bulk delete tests need refinement (event simulation) - tracked
separately.
2025-11-28 00:05:11 +00:00
Wikid82 ab334a2315 feat: add bulk delete with auto-backup and improve bulk actions UX
- Add bulk delete button with automatic backup creation before deletion
- Move checkbox column to the right side of the table
- Show '(all)' indicator when all hosts are selected
- Add comprehensive delete confirmation modal listing affected hosts
- Rename 'Bulk Actions' button to 'Manage ACL' for clarity
- Add CGNAT warning banner in Access Lists page
  - Explains why mobile/CGNAT connections may be blocked
  - Provides solutions for locked-out users
  - Includes tips for T-Mobile 5G, Starlink, and other CGNAT ISPs
- Improve error messaging and loading states

Addresses common CGNAT issues where geo-blocking fails due to
data center IP addresses appearing instead of user's location.
2025-11-27 17:43:20 +00:00
Wikid82 33e91e21c5 Remove obsolete implementation documents and scripts; delete SECURITY_IMPLEMENTATION_PLAN.md, VERSIONING_IMPLEMENTATION.md, cookies.txt, create_issues.sh, and frontend coverage report. 2025-11-27 16:11:02 +00:00
Wikid82 2f23cf1251 chore: update ignore files and add issue creation script
- Add *.cover and coverage_*.out patterns to gitignore
- Add recursive database patterns (backend/data/**/*.db)
- Ignore project implementation documentation files
- Add cookies.txt and create_issues.sh to gitignore
- Update dockerignore with same patterns for smaller images
- Add *.cover and *.out to codecov ignore
- Add scripts/create_bulk_acl_issues.sh for automated issue creation
2025-11-27 16:08:29 +00:00
Wikid82 459f9b6c52 docs: add comprehensive testing documentation for bulk ACL feature
- Create main testing issue with 64 manual test checkpoints
- Add 7 sub-issue templates for organized testing workflow
- Cover basic functionality, error handling, UI/UX, integration, cross-browser, and regression testing
- Include test environment setup and success criteria
2025-11-27 15:03:42 +00:00
Wikid82 05321e3a59 feat: implement bulk ACL application feature for proxy hosts 2025-11-27 14:55:00 +00:00
Wikid82 429de10f0f feat: implement access list management with CRUD operations and IP testing
- Added API integration for access lists including listing, creating, updating, deleting, and testing IPs against access lists.
- Created AccessListForm component for creating and editing access lists with validation.
- Developed AccessListSelector component for selecting access lists with detailed display of selected ACL.
- Implemented hooks for managing access lists and handling API interactions.
- Added tests for AccessListSelector and useAccessLists hooks to ensure functionality.
- Enhanced AccessLists page with UI for managing access lists, including create, edit, delete, and test IP features.
2025-11-27 08:55:29 +00:00
Wikid82 486c9b40c1 feat: Add security scanning capabilities with govulncheck and Trivy integration 2025-11-27 06:01:32 +00:00
Wikid82 51664416b6 feat: Enhance ProxyHost configuration with application presets and internal IP support 2025-11-27 03:54:41 +00:00
Jeremy 09231ed6da Merge pull request #242 from Wikid82/copilot/add-cgnat-solver-feature
docs: Add Plex Remote Access Helper & CGNAT Solver issue specification
2025-11-26 19:37:50 -05:00
copilot-swe-agent[bot] 3ca72b157c feat: Add Plex Remote Access Helper & CGNAT Solver issue specification
Co-authored-by: Wikid82 <176516789+Wikid82@users.noreply.github.com>
2025-11-27 00:20:03 +00:00
copilot-swe-agent[bot] 121021450f Initial plan 2025-11-27 00:16:09 +00:00
Wikid82 1efc18940c feat: enable security services in docker-compose and update logs navigation 2025-11-26 21:07:24 +00:00
Wikid82 6f55ac99c3 fix: improve uptime service test reliability by using local closed port 2025-11-26 18:57:19 +00:00
Wikid82 6e646eee3c fix: update security docs link and workflow base path 2025-11-26 18:53:10 +00:00
Wikid82 e8f52b6d18 docs: fix navigation links for cpmp base path 2025-11-26 18:52:56 +00:00
Wikid82 58edf44f3c feat: update security configuration and documentation for new service modes 2025-11-26 18:47:31 +00:00
Jeremy 1b9cf98b9d docs: update security env vars to use MODE 2025-11-26 13:46:37 -05:00
Jeremy a232824f64 docs: add security documentation 2025-11-26 13:40:19 -05:00
Wikid82 c8a452f1a0 feat: implement modular security services with CrowdSec and WAF integration 2025-11-26 18:35:14 +00:00
Wikid82 06d0aca8a4 feat: update favicon and adjust logo/banner display in layout 2025-11-26 14:23:44 +00:00
Jeremy 7465a24347 Merge pull request #239 from Wikid82/development
Propagate changes from development into feature/beta-release
2025-11-26 08:37:18 -05:00
Jeremy 804e1417fa Merge pull request #238 from Wikid82/renovate/npm-minorpatch
fix(deps): update npm minor/patch
2025-11-26 08:36:47 -05:00
renovate[bot] 9177737a60 fix(deps): update npm minor/patch 2025-11-26 09:53:57 +00:00
Wikid82 ba05c5e945 feat: adjust banner image layout for better alignment and size 2025-11-26 04:05:37 +00:00
Wikid82 20cf3d1010 feat: update banner image dimensions for improved responsiveness 2025-11-26 03:57:18 +00:00
Wikid82 1891cbb75a feat: add static file serving for banner, logo, and favicon in router 2025-11-26 03:49:47 +00:00
Wikid82 e459978797 feat: replace title with banner image in mobile header and main layout 2025-11-26 03:49:47 +00:00
Jeremy f811983473 Merge pull request #237 from Wikid82/development
Propagate changes from development into feature/beta-release
2025-11-25 22:49:20 -05:00
Jeremy 5307b4fe5e Merge branch 'feature/beta-release' into development 2025-11-25 22:49:03 -05:00
Wikid82 b2cd09ae24 feat: add new public assets and update pre-commit configuration for large files 2025-11-25 22:11:03 -05:00
Wikid82 a52108bfd1 feat: update MonitorCard layout for improved status display and settings access 2025-11-26 01:45:46 +00:00
Wikid82 3015682394 feat: enhance MonitorCard layout with status badge and settings button 2025-11-26 01:41:15 +00:00
Wikid82 a8a95793b7 fix: adjust styling for MonitorCard status indicator for better alignment 2025-11-26 01:34:08 +00:00
Wikid82 56903b0e06 chore: clean up unused files and empty code blocks 2025-11-26 01:12:52 +00:00
Wikid82 4f03021c9c refactor: remove security-related hooks and pages
- Deleted `useSecurity.ts` hook which managed authentication users, providers, and policies.
- Removed `Policies.tsx`, `Providers.tsx`, and `Users.tsx` pages that utilized the above hook.
- Cleaned up the `index.tsx` file in the Security section to remove references to the deleted pages.
- Updated mock data by removing unused properties related to forward authentication.
2025-11-26 00:02:15 +00:00
Wikid82 b20522f150 feat: add IdentityStore support to security app configuration 2025-11-25 23:06:05 +00:00
Wikid82 96920c2b20 feat: refactor security app configuration to use unified SecurityConfig structure 2025-11-25 22:43:09 +00:00
Wikid82 b1fdcfbb1b feat: add HelpTooltip component for improved OAuth field guidance 2025-11-25 22:23:02 +00:00
Wikid82 a3c164a394 refactor: update error handling to use unknown type for better type safety 2025-11-25 22:08:54 +00:00
Wikid82 5bfa2975be feat: add ESLint configuration for frontend files and ignore patterns 2025-11-25 22:08:31 +00:00
Wikid82 abea3e8de8 feat: add newline for better readability in pre-commit configuration 2025-11-25 22:07:40 +00:00
renovate[bot] 69863ae6fb fix(deps): update npm minor/patch (#235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 20:57:52 +00:00
Wikid82 68db3b1926 fix: adjust modal width and tooltip positioning in Providers component 2025-11-25 18:36:10 +00:00
Wikid82 8c36a8dee4 feat: add support for additional emails in user management and update related configurations 2025-11-25 18:30:16 +00:00
Wikid82 07be2155be Refactor Security Management: Split Security page into Users, Providers, and Policies components; remove deprecated Security component; implement CRUD functionality for users, providers, and policies; enhance Uptime page with monitor editing capabilities. 2025-11-25 14:53:06 +00:00
Wikid82 7a1f577771 feat: add forward authentication configuration and UI
- Introduced ForwardAuthConfig model to store global forward authentication settings.
- Updated Manager to fetch and apply forward authentication configuration.
- Added ForwardAuthHandler to create a reverse proxy handler for authentication.
- Enhanced ProxyHost model to include forward authentication options.
- Created Security page and ForwardAuthSettings component for managing authentication settings.
- Implemented API endpoints for fetching and updating forward authentication configuration.
- Added tests for new functionality including validation and error handling.
- Updated frontend components to support forward authentication settings.
2025-11-25 13:25:05 +00:00
Jeremy 6f82659d14 Merge pull request #226 from Wikid82/development
Propagate changes from development into feature/beta-release
2025-11-24 23:18:52 -05:00
Jeremy bc5e518b0d Merge pull request #224 from Wikid82/renovate/github-codeql-action-4.x
chore(deps): update github/codeql-action action to v4
2025-11-24 23:18:23 -05:00
Jeremy 0370f54f8e Merge pull request #223 from Wikid82/renovate/major-5-github-artifact-actions
chore(deps): update actions/upload-artifact action to v5
2025-11-24 23:18:07 -05:00
Jeremy c861451b3d Merge pull request #222 from Wikid82/renovate/node-24.x
chore(deps): update dependency node to v24
2025-11-24 23:17:49 -05:00
Jeremy 6349be5c26 Merge pull request #220 from Wikid82/renovate/actions-setup-node-6.x
chore(deps): update actions/setup-node action to v6
2025-11-24 23:17:25 -05:00
renovate[bot] 483426aa35 chore(deps): update dependency node to v24 2025-11-25 04:17:25 +00:00
Jeremy 4e6feb5fdc Merge pull request #221 from Wikid82/renovate/node-22.x
chore(deps): update dependency node to v22
2025-11-24 23:17:08 -05:00
renovate[bot] 1f454720e4 chore(deps): update actions/setup-node action to v6 2025-11-25 04:16:32 +00:00
Jeremy 5061f5ef5c Merge pull request #219 from Wikid82/renovate/actions-setup-node-5.x
chore(deps): update actions/setup-node action to v5
2025-11-24 23:15:48 -05:00
renovate[bot] b9a2f705c6 chore(deps): update actions/upload-artifact action to v5 2025-11-25 04:15:41 +00:00
Jeremy 801eab6293 Merge pull request #218 from Wikid82/renovate/actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6
2025-11-24 23:15:30 -05:00
Jeremy 02adfced97 Merge pull request #217 from Wikid82/renovate/major-6-github-artifact-actions
chore(deps): update actions/download-artifact action to v6
2025-11-24 23:15:06 -05:00
renovate[bot] 16c113580d fix(deps): update npm minor/patch (#225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 04:12:08 +00:00
Wikid82 6d41344448 feat: add docker-compose.local.yml for local development setup 2025-11-25 04:11:21 +00:00
Wikid82 3006afcbad feat: enhance test summaries for backend and frontend workflows 2025-11-25 04:08:24 +00:00
Wikid82 78b782974e feat: add custom name handling for proxy hosts and enhance import functionality 2025-11-25 03:50:23 +00:00
Wikid82 dea012d471 feat: improve sorting logic in CertificateList by adding block scope for case statements 2025-11-25 02:52:35 +00:00
Wikid82 cc6bc7d6d6 feat: add name field to ProxyHost and implement sorting functionality in ProxyHosts and CertificateList components 2025-11-25 02:50:32 +00:00
Wikid82 ea034ba102 feat: enhance SSL detection in importer and improve certificate status handling in ProxyHosts 2025-11-25 02:31:02 +00:00
Wikid82 c6dbd1291c feat: implement caching for certificate retrieval and enhance sync functionality 2025-11-25 02:16:53 +00:00
Wikid82 72975c674a feat: enhance certificate handling with staging support and update UI for untrusted status 2025-11-25 01:53:58 +00:00
Wikid82 017ee4f8bd feat: clean up invalid Let's Encrypt certificate associations and update UI to reflect custom certificate usage 2025-11-25 01:18:11 +00:00
Wikid82 5bfe923e37 feat: add Certificate interface and update ProxyHostForm and ProxyHosts components to display certificate details 2025-11-25 01:06:45 +00:00
renovate[bot] 0bb6526922 chore(deps): update github/codeql-action action to v4 2025-11-25 00:58:39 +00:00
Wikid82 0415f5da77 feat: enhance import handling with overwrite support and detailed conflict resolution
feat: add subroute handler extraction for improved Caddyfile parsing
test: add tests for subroute handler extraction functionality
fix: update UI to display staging certificate status and improve dashboard metrics
docs: clarify staging certificate deletion process in ACME documentation
2025-11-25 00:35:42 +00:00
Wikid82 897959a621 feat: enhance import handling with detailed conflict resolution and UI updates 2025-11-24 23:48:25 +00:00
Wikid82 e4b737710c feat: implement ACME staging support for Let's Encrypt to avoid rate limits during development and testing 2025-11-24 23:10:07 +00:00
renovate[bot] fdb15150da chore(deps): update dependency node to v22 2025-11-24 20:35:37 +00:00
renovate[bot] aec54e6bcc chore(deps): update actions/setup-node action to v5 2025-11-24 20:35:28 +00:00
renovate[bot] 88eaa4ed3a chore(deps): update actions/setup-go action to v6 2025-11-24 20:35:24 +00:00
renovate[bot] 59f614cf29 chore(deps): update actions/download-artifact action to v6 2025-11-24 20:35:20 +00:00
Wikid82 931e35b5f3 Refactor code structure for improved readability and maintainability 2025-11-24 20:33:01 +00:00
Wikid82 ad98d9fea2 feat: add multi-file upload and import detection endpoints to ImportHandler 2025-11-24 18:55:53 +00:00
Wikid82 5de6f9b714 feat: add Docker management tasks and update import handler for transient preview 2025-11-24 18:40:38 +00:00
Wikid82 6feff3e8ce chore: remove cashed 2025-11-24 18:22:01 +00:00
Wikid82 9c842e7eab chore: remove cached 2025-11-24 18:21:11 +00:00
Wikid82 5b041819bb feat: add Git Remove Cached task to VSCode tasks 2025-11-24 18:20:21 +00:00
Wikid82 0da7d5dbf7 feat: add docker-compose.local.yml to .gitignore 2025-11-24 18:16:48 +00:00
Wikid82 af5a0b4ef8 feat: add transient import preview and commit functionality with tests 2025-11-24 18:14:59 +00:00
Wikid82 a698dff33a feat: implement transient import previews and persist-on-commit
- Refactor import handler to support transient previews for uploads and mounted Caddyfiles
- Import sessions are now created only when user commits (not on upload/mount)
- Upload endpoint saves files to import/uploads/<uuid>.caddyfile and returns preview without DB persistence
- GetPreview supports both DB-backed sessions and transient previews from mountPath
- Commit endpoint handles both DB sessions and transient uploads/mounts, persisting session on commit
- Cancel endpoint removes DB sessions or deletes transient uploaded files
- CheckMountedImport no longer auto-creates DB sessions (transient preview behavior)
- Add conflict detection for transient previews against existing ProxyHosts
- Update importer to return raw domain names in conflicts array instead of formatted messages
- Fix import handler Cancel to return 404 for non-existent sessions
- Update all tests to match new transient preview behavior
- Install Node.js 20.x for pre-commit hooks
- Adjust coverage threshold to 78% (78.2% actual)
2025-11-24 17:43:55 +00:00
Wikid82 8babd2f430 feat: Enhance import handler to support mounted Caddyfile and improve conflict reporting 2025-11-24 17:32:56 +00:00
Wikid82 fce717f7d9 feat: Update launch configuration and docker-compose for backend debugging 2025-11-24 16:38:25 +00:00
Wikid82 c42e4d5ab9 feat: Add .vscode/launch.json to .gitignore 2025-11-24 16:38:25 +00:00
Jeremy 933f37ab18 Merge pull request #202 from Wikid82/development
Propagate changes from development into feature/beta-release
2025-11-24 11:33:52 -05:00
Jeremy efb454c96a Merge pull request #212 from Wikid82/renovate/docker-setup-qemu-action-3.x
chore(deps): update docker/setup-qemu-action action to v3.7.0
2025-11-24 11:32:38 -05:00
Jeremy 740b951e34 Merge pull request #205 from Wikid82/renovate/aquasecurity-trivy-action-0.x
chore(deps): update aquasecurity/trivy-action action to v0.33.1
2025-11-24 11:32:20 -05:00
Jeremy e076748cb4 Merge pull request #198 from Wikid82/renovate/github-codeql-action-digest
chore(deps): update github/codeql-action digest to fdbfb4d
2025-11-24 11:30:40 -05:00
Jeremy a09104bf89 Merge pull request #216 from Wikid82/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2025-11-24 11:30:18 -05:00
Jeremy 2323774476 Merge pull request #214 from Wikid82/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2.4.2
2025-11-24 11:29:34 -05:00
renovate[bot] 7e921bfeb0 chore(deps): update actions/checkout action to v6 2025-11-24 16:29:11 +00:00
renovate[bot] 24b7afe7f4 chore(deps): update github/codeql-action digest to fdbfb4d 2025-11-24 16:29:00 +00:00
Jeremy 08fc1b4e5a Merge pull request #213 from Wikid82/renovate/github-codeql-action-3.x
chore(deps): update github/codeql-action action to v3.31.5
2025-11-24 11:28:09 -05:00
Jeremy 79ed26f66c Merge pull request #206 from Wikid82/renovate/go-1.x
chore(deps): update dependency go to v1.25.4
2025-11-24 11:27:52 -05:00
renovate[bot] e8659d528e chore(deps): update softprops/action-gh-release action to v2.4.2 2025-11-24 16:27:48 +00:00
renovate[bot] 58358b3834 chore(deps): update github/codeql-action action to v3.31.5 2025-11-24 16:27:42 +00:00
renovate[bot] e396d2ea25 chore(deps): update docker/setup-qemu-action action to v3.7.0 2025-11-24 16:27:38 +00:00
renovate[bot] 6f10e31d66 chore(deps): update aquasecurity/trivy-action action to v0.33.1 2025-11-24 16:27:34 +00:00
Jeremy 22e280c9f4 Merge pull request #207 from Wikid82/renovate/node-20.x
chore(deps): update dependency node to v20.19.5
2025-11-24 11:27:32 -05:00
Jeremy 166f9970a6 Merge pull request #209 from Wikid82/renovate/docker-login-action-3.x
chore(deps): update docker/login-action action to v3.6.0
2025-11-24 11:27:06 -05:00
Jeremy 230bc3dfd8 Merge pull request #211 from Wikid82/renovate/docker-setup-buildx-action-3.x
chore(deps): update docker/setup-buildx-action action to v3.11.1
2025-11-24 11:26:47 -05:00
Jeremy 57e4fc1a2a Merge pull request #197 from Wikid82/renovate/pin-dependencies
chore(deps): pin dependencies
2025-11-24 11:26:24 -05:00
renovate[bot] e54de9a80c chore(deps): update docker/setup-buildx-action action to v3.11.1 2025-11-24 13:01:46 +00:00
renovate[bot] d5b322eff2 chore(deps): update docker/metadata-action action to v5.9.0 (#210)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 13:01:24 +00:00
renovate[bot] c40972e3c4 chore(deps): update renovatebot/github-action action to v44.0.4 (#208)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 08:49:19 +00:00
renovate[bot] 769d38e8ec chore(deps): update docker/login-action action to v3.6.0 2025-11-24 05:00:22 +00:00
renovate[bot] 55dd97dd76 chore(deps): update actions/upload-artifact action to v4.6.2 (#204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 04:59:48 +00:00
renovate[bot] 744d3485dd chore(deps): update dependency node to v20.19.5 2025-11-24 04:18:04 +00:00
renovate[bot] 893e706c7e chore(deps): update dependency go to v1.25.4 2025-11-24 04:18:00 +00:00
renovate[bot] 7cf7da2300 chore(deps): update actions/download-artifact action to v4.3.0 (#200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 04:17:29 +00:00
Wikid82 7da561de56 feat: Update notification type in UptimeService based on monitor status changes 2025-11-24 04:10:02 +00:00
Wikid82 9f54438955 Add tests for error handling in backup, notification, and proxy host handlers; enhance certificate service with persistence tests; refactor backup service for scheduled backups; improve frontend form handling and add API tests for certificates, domains, and proxy hosts; implement frontend test coverage script. 2025-11-24 03:52:37 +00:00
Wikid82 b0f5466967 feat: Enhance CertificateService to scan Caddy data directory for auto-generated certificates 2025-11-24 03:20:09 +00:00
Wikid82 71cb2bea92 feat: Update certificate service path to use configurable Caddy data directory 2025-11-24 02:41:42 +00:00
Wikid82 247c7d1d7b feat: Preload Locations and Certificate for proxy hosts in ApplyConfig 2025-11-24 02:32:52 +00:00
Wikid82 65ce88d59c feat: Enhance SyncMonitors to support SSL and upgrade monitor URLs to HTTPS 2025-11-24 01:02:42 +00:00
renovate[bot] 9d366ff7c9 chore(deps): pin dependencies 2025-11-24 00:59:32 +00:00
Wikid82 1fb3e59824 refactor: Clean up formatting and spacing in Notifications and Uptime components 2025-11-24 00:51:08 +00:00
Wikid82 51a6c40b37 feat: Update UptimeService to check public URLs and migrate existing monitors 2025-11-24 00:50:23 +00:00
Wikid82 f39a1b7e95 feat: Implement initial Caddy config synchronization with readiness check 2025-11-24 00:38:14 +00:00
Wikid82 1107a892b2 feat: Make domain names clickable with configurable open behavior
- Added 'Domain Link Behavior' setting to System Settings (Same Tab, New Tab, New Window)
- Updated Proxy Hosts list to render domains as clickable links
- Implemented behavior logic:
  - Same Tab: Opens in current tab
  - New Tab: Opens in new tab (default)
  - New Window: Opens in a new popup window
2025-11-23 23:54:04 +00:00
Wikid82 642d4d2437 feat: Enhance Uptime heartbeat bar
- Frontend: Increase heartbeat history to 60 items (1 hour)
- Frontend: Add empty bars for alignment when history is sparse
- Frontend: Improve tooltips with detailed status info
- Frontend: Update API client to support limit parameter
2025-11-23 23:50:04 +00:00
Wikid82 527c54582f feat: Add notification test button and fix Discord webhook URL support
- Frontend: Add Test button to Notification Provider form with visual feedback
- Backend: Normalize Discord HTTPS webhook URLs to Shoutrrr format (discord://)
- Backend: Create system notification on test failure for better visibility
2025-11-23 22:58:07 +00:00
Wikid82 6f669d0d7a chore: Update .dockerignore to include additional frontend and backend artifacts 2025-11-23 22:55:32 +00:00
Wikid82 8120806c68 chore: Update .gitignore and commit remaining frontend changes 2025-11-23 20:44:19 +00:00
Wikid82 1b12dbaf8b feat: Update frontend deps, improve backend coverage, add notification/uptime features
- Upgrade frontend to Node v20
- Fix frontend tests
- Improve backend test coverage to >80%
- Add Notification Provider and Uptime monitoring features
- Fix SQLite locking issues in tests
2025-11-23 20:42:18 +00:00
Wikid82 a97faf3ba1 chore: update Dockerfile and docker-compose for timezone consistency and improved dependencies 2025-11-23 16:48:04 +00:00
Wikid82 22d3555811 chore: refactor certificate service and hook for improved clarity and initialization 2025-11-23 16:39:13 +00:00
Wikid82 d790eb88f6 chore: update certificate routes to use standard Caddy data directory 2025-11-23 16:36:06 +00:00
Wikid82 aa4ca708c6 chore: add timezone environment variable for consistency in Docker configuration 2025-11-23 16:34:27 +00:00
Wikid82 0fe5c6fa92 chore: update layout styles for improved responsiveness and add parsing utility 2025-11-23 16:29:25 +00:00
Wikid82 e47121f267 chore: update configuration files and styles for improved debugging and layout 2025-11-23 16:19:15 +00:00
Wikid82 4f771e2216 chore: restore VSCode configuration files for debugging and task automation 2025-11-23 10:40:12 -05:00
Jeremy d3f3d39fbd Merge pull request #196 from Wikid82/development
Propagate changes from development into feature/beta-release
2025-11-23 10:29:08 -05:00
Jeremy 0a0f0b1353 Merge pull request #195 from Wikid82/main
Propagate changes from main into development
2025-11-23 10:28:30 -05:00
Wikid82 534b19d308 chore: add initial workspace configuration file 2025-11-23 10:27:23 -05:00
Wikid82 01c4201b19 chore: remove .vscode and *.code-workspace from .gitignore 2025-11-23 10:26:15 -05:00
Wikid82 ce89c63afc feat: implement certificate upload and deletion functionality, enhance certificate management in the API and frontend 2025-11-22 23:05:23 -05:00
Wikid82 155bedcf66 feat: add SSL provider selection to settings and update config generation 2025-11-22 22:22:22 -05:00
Wikid82 e66db3c27f fix: remove unsupported email field from zerossl issuer config 2025-11-22 22:14:15 -05:00
Wikid82 f258317190 fix: improve error handling in proxy host form submission 2025-11-22 22:02:48 -05:00
Wikid82 3730ce3152 fix: normalize domains to lowercase in caddy config, improve error logging 2025-11-22 21:47:40 -05:00
Wikid82 c93a5edd0e feat: enhance container selection logic for remote servers by prioritizing host IP and mapped public ports 2025-11-22 21:26:09 -05:00
Wikid82 3aa0d618a2 fix: deduplicate domains in caddy config generation to prevent 500 errors on ghost hosts, and fix remote server container selection 2025-11-22 21:22:26 -05:00
Wikid82 ba67cc2274 fix: update remote server form tests for new test connection feature 2025-11-22 20:14:31 -05:00
Wikid82 efc1103af4 feat: add test connection button to remote server form 2025-11-22 20:09:53 -05:00
Wikid82 557f9af19b feat: add loading indicators for instant feedback on list updates 2025-11-22 19:55:55 -05:00
Wikid82 09b51a933b fix: rollback proxy host on config failure and add TZ support 2025-11-22 19:43:01 -05:00
Wikid82 2d9d007a51 chore: add host.docker.internal to docker-compose 2025-11-22 19:33:39 -05:00
Wikid82 2d7278b959 feat: add a default "Site Not Configured" page with dynamic admin link 2025-11-22 19:30:20 -05:00
Wikid82 c6771be7a8 feat: add default 404 page for unknown hosts 2025-11-22 19:26:38 -05:00
Wikid82 ef82386b22 feat: enhance import logging and improve host parsing in importer 2025-11-22 16:53:05 -05:00
Wikid82 933ec88c83 feat: implement temporary file creation for log downloads to prevent Content-Length mismatches 2025-11-22 16:35:04 -05:00
Wikid82 2a1e91c50b fix: add remote_logs/Unconfirmed 312410.crdownload to .gitignore 2025-11-22 16:27:22 -05:00
Jeremy 379b83e139 Merge pull request #193 from Wikid82/development
Propagate changes from development into feature/beta-release
2025-11-22 16:05:17 -05:00
Jeremy 5e065c4fa5 Merge pull request #194 from Wikid82/main
Propagate changes from main into development
2025-11-22 16:04:49 -05:00
Jeremy efff676501 Merge pull request #190 from Wikid82/development
release: Alpha Completion & Beta Start
2025-11-22 16:03:22 -05:00
Jeremy cd4cdc1784 Merge pull request #189 from Wikid82/feature/alpha-completion
feat: Complete Alpha Milestone
2025-11-22 16:02:59 -05:00
Jeremy c167a3788c Merge pull request #191 from Wikid82/feature/beta-release
Beta Release: PROJECT_TOKEN migration & docs
2025-11-22 16:02:30 -05:00
Wikid82 8a60325464 feat: enhance import session handling by adding session UUID to commitImport function 2025-11-22 15:58:12 -05:00
Wikid82 185121d9f0 feat: add comments to clarify trusted proxies configuration in NewRouter 2025-11-22 15:13:49 -05:00
Wikid82 bbf59f0d6a fix: update Trivy SARIF upload token to use GITHUB_TOKEN instead of CPMP_TOKEN 2025-11-22 15:07:57 -05:00
Wikid82 a5042cab55 fix: update workflows to replace PROJECT_TOKEN and GITHUB_TOKEN with CPMP_TOKEN 2025-11-22 14:53:25 -05:00
Wikid82 1bb592d7cd fix: update token for Trivy SARIF upload to use CPMP_GHPR_TOKEN 2025-11-22 14:44:54 -05:00
Wikid82 44ecea5349 fix: normalize image name case for Trivy scan 2025-11-22 14:35:02 -05:00
Wikid82 0b46bb740c fix: correct formatting of resolutions map in TestImportHandler_Commit_Errors 2025-11-22 14:29:42 -05:00
Wikid82 fea86a6c76 feat: enhance import functionality with Caddyfile content preview and optional mounting in Docker 2025-11-22 14:29:21 -05:00
Wikid82 19230e1400 fix: use PROJECT_TOKEN for trivy sarif upload 2025-11-22 13:55:38 -05:00
Wikid82 d58f6eae5f chore: rename service from 'app' to 'cpmp' in docker-compose.yml 2025-11-22 13:45:56 -05:00
Wikid82 9b4cdea963 perf: update release workflow and improve test setup; add lazy loading for better performance 2025-11-22 13:21:34 -05:00
Jeremy c8adbb79fd chore: lock PR body content before creation 2025-11-22 12:21:44 -05:00
Jeremy 308ae5dd9d chore: final PR body content prior to draft PR creation 2025-11-22 12:21:37 -05:00
Jeremy b81544564a chore: update PR body content file before opening draft PR 2025-11-22 12:21:30 -05:00
Jeremy 588c751102 chore: update PR body content file with newest commit reference 2025-11-22 12:21:23 -05:00
Jeremy b142c38ffd chore: update PR body content file with latest commit reference 2025-11-22 12:21:14 -05:00
Jeremy 18cf3cee52 chore: add PR body content file for draft creation 2025-11-22 12:21:03 -05:00
Jeremy e2fc0b80eb docs: refresh draft PR body snapshot with final commit reference before PR 2025-11-22 12:20:55 -05:00
Jeremy 5727c58652 docs: refresh draft PR body snapshot with latest commit SHA 2025-11-22 12:20:49 -05:00
Jeremy 75beff0910 docs: update concise draft PR body snapshot with latest commit reference 2025-11-22 12:20:43 -05:00
Jeremy 1cf0d267b0 chore: add concise draft PR body snapshot 2025-11-22 12:20:37 -05:00
Jeremy be131efd2e docs: update beta-release draft PR summary with thirtieth update 2025-11-22 12:20:26 -05:00
Jeremy c99723dfc5 docs: update beta-release draft PR summary with twenty-ninth update 2025-11-22 12:20:14 -05:00
Jeremy 3cd9875477 docs: update beta-release draft PR summary with twenty-eighth update 2025-11-22 12:20:06 -05:00
Jeremy 28a793d998 docs: update beta-release draft PR summary with twenty-seventh update 2025-11-22 12:19:57 -05:00
Jeremy 3adc86025b docs: update beta-release draft PR summary with twenty-sixth update 2025-11-22 12:19:49 -05:00
Jeremy 853f0f13d1 docs: update beta-release draft PR summary with twenty-fifth update 2025-11-22 12:19:42 -05:00
Jeremy ae918bf018 docs: update beta-release draft PR summary with twenty-fourth update 2025-11-22 12:19:33 -05:00
Jeremy 19aeb429da docs: update beta-release draft PR summary with twenty-third update 2025-11-22 12:19:25 -05:00
Jeremy 5addf23cd5 docs: update beta-release draft PR summary with twenty-second update 2025-11-22 12:19:17 -05:00
Jeremy c960f182a1 docs: update beta-release draft PR summary with twenty-first update 2025-11-22 12:19:09 -05:00
Jeremy 119364f144 docs: update beta-release draft PR summary with twentieth update 2025-11-22 12:19:00 -05:00
Jeremy 9169e6182b docs: update beta-release draft PR summary with nineteenth update 2025-11-22 12:18:50 -05:00
Jeremy 5a6aec15e6 docs: update beta-release draft PR summary with eighteenth update 2025-11-22 12:18:43 -05:00
Jeremy 7e32857473 docs: update beta-release draft PR summary with seventeenth update 2025-11-22 12:18:36 -05:00
Jeremy ed13d679c0 docs: update beta-release draft PR summary with sixteenth update 2025-11-22 12:18:29 -05:00
Jeremy 990161cd62 docs: update beta-release draft PR summary with fifteenth update 2025-11-22 12:18:22 -05:00
Jeremy 49b13cc829 docs: update beta-release draft PR summary with fourteenth update 2025-11-22 12:18:15 -05:00
Jeremy 41edb5aeb9 docs: update beta-release draft PR summary with thirteenth update 2025-11-22 12:18:09 -05:00
Jeremy 44c2fba162 docs: update beta-release draft PR summary with twelfth update 2025-11-22 12:18:01 -05:00
Jeremy 54f15853bd docs: update beta-release draft PR summary with eleventh update (retry) 2025-11-22 12:17:52 -05:00
Jeremy e116e081f7 docs: update beta-release draft PR summary with tenth update 2025-11-22 12:17:39 -05:00
Jeremy 7483dd0f2c docs: update beta-release draft PR summary with ninth update 2025-11-22 12:17:32 -05:00
Jeremy 3a410b8b18 docs: update beta-release draft PR summary with eighth update 2025-11-22 12:17:25 -05:00
Jeremy a0c84c7ef4 docs: update beta-release draft PR summary with seventh update 2025-11-22 12:17:19 -05:00
Jeremy dddfebb9de docs: update beta-release draft PR summary with sixth update 2025-11-22 12:17:13 -05:00
Jeremy 6b3b9e3704 docs: update beta-release draft PR summary with fifth update 2025-11-22 12:17:07 -05:00
Jeremy 4843ecad78 docs: update beta-release draft PR summary with fourth update 2025-11-22 12:17:02 -05:00
Jeremy 178e7ed096 docs: update beta-release draft PR summary with third update 2025-11-22 12:16:56 -05:00
Jeremy 18c3621a89 docs: update beta-release draft PR summary with second update 2025-11-22 12:16:50 -05:00
Jeremy fc0851472b docs: update beta-release draft PR summary with new commit 2025-11-22 12:16:43 -05:00
Jeremy 0449681541 docs: add beta-release draft PR summary 2025-11-22 12:16:38 -05:00
Wikid82 6c8ba7b42d fix: replace GITHUB_TOKEN with PROJECT_TOKEN in workflows 2025-11-22 12:15:13 -05:00
Wikid82 de1160a320 fix: revert to PROJECT_TOKEN 2025-11-22 12:13:55 -05:00
Wikid82 7aee12b911 fix: use GITHUB_TOKEN in release workflow 2025-11-22 12:11:50 -05:00
Jeremy bf30a4aef2 Merge pull request #188 from Wikid82/feature/beta-release
chore: Prepare Beta Release (v0.2.0-beta.1)
2025-11-22 09:40:46 -05:00
Wikid82 fa4c189731 ci: fix action versions in release workflow 2025-11-22 09:23:40 -05:00
Wikid82 1475e2ada8 ci: fix action versions and use tags for stability 2025-11-22 09:21:39 -05:00
Wikid82 166bca30bf ci: guard upload-sarif step when Trivy SARIF missing 2025-11-22 09:17:47 -05:00
Wikid82 a0b2d3ffef fix: use find to locate and move dlv binary reliably 2025-11-22 00:43:23 -05:00
Wikid82 4d8c6fbb13 fix: handle dlv binary location dynamically without GOBIN 2025-11-22 00:33:10 -05:00
Wikid82 61b13a4b2b fix: use GOBIN to force dlv installation path in Dockerfile 2025-11-22 00:31:08 -05:00
Wikid82 902a09d686 fix: ensure dlv binary is verifiable and copied correctly in multi-arch build 2025-11-22 00:23:59 -05:00
Wikid82 bc252ebebe feat: update docker-publish workflow for beta release and add version bump script 2025-11-22 00:15:54 -05:00
Wikid82 5fe1791f50 chore: bump version to 0.2.0-beta.1 2025-11-22 00:15:05 -05:00
Wikid82 902603d5ad feat: add frontend test hook and update test cases for ProxyHostForm and SystemStatus components 2025-11-21 23:39:06 -05:00
Jeremy 7859337319 Merge pull request #187 from Wikid82/development
Propagate changes from development into feature/alpha-completion
2025-11-21 22:45:54 -05:00
Wikid82 807481ee4e feat: enhance sidebar navigation with collapsible menus and icons for better usability 2025-11-21 21:55:42 -05:00
Wikid82 7aa6747ecc feat: add Settings and Tasks pages with routing; enhance navigation links for better user experience 2025-11-21 21:47:43 -05:00
Wikid82 3256cc845b feat: add log level filtering and enhance logging configuration; update UI for improved navigation and settings structure 2025-11-21 21:32:43 -05:00
Wikid82 cdc6630a79 feat: add Content-Disposition header for file downloads in backup and logs handlers 2025-11-21 20:57:41 -05:00
renovate[bot] be4fe1a19b chore(deps): update npm minor/patch to ^4.0.13 (#186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-22 01:55:56 +00:00
Wikid82 94592c8515 feat: integrate Caddy manager into proxy host handler and enhance authorization middleware; update frontend components for improved UI and functionality 2025-11-21 20:42:52 -05:00
Wikid82 870af044f8 feat: add test connection functionality for proxy hosts and enhance UI for testing connections 2025-11-21 18:06:48 -05:00
Wikid82 b3c56529a6 feat: add suffix lookup functionality with trie structure
- Implemented a new suffix-trie.ts file for efficient domain suffix lookups.
- Introduced a lookupInTrie function to search for public suffixes in a trie.
- Added suffixLookup function to check if a hostname has a valid public suffix.
- Created package.json and package-lock.json to manage dependencies, including tldts and tldts-core.
2025-11-21 17:43:43 -05:00
Wikid82 1ba719366b fix: update default values for SSL and HTTP2 settings; reorganize domain names section 2025-11-21 17:22:05 -05:00
Wikid82 f26c804f3f Refactor code structure for improved readability and maintainability 2025-11-21 16:51:26 -05:00
Wikid82 4f93b8b6c5 fix: add specific SARIF files to .gitignore for better exclusion management 2025-11-21 16:39:09 -05:00
Wikid82 eb0628ee63 fix: add exclusion for all SARIF files in .gitignore 2025-11-21 16:35:34 -05:00
Wikid82 c52c96df69 fix: update connection handling in ProxyHostForm and improve tooltip descriptions in useDocker 2025-11-21 16:29:42 -05:00
Wikid82 a5fd7b02f3 fix: reorder import statements in domain_handler.go for consistency 2025-11-21 16:16:18 -05:00
Wikid82 cf23ddb666 feat: add domain management functionality with CRUD operations and integrate into UI 2025-11-21 16:15:39 -05:00
Wikid82 f6bd3ecb59 feat: enhance NotificationCenter with system update notifications and improve ProxyHostForm connection source handling 2025-11-21 15:09:18 -05:00
Wikid82 1a29b7ae76 feat: center header title and improve email update confirmation message 2025-11-21 14:26:42 -05:00
Wikid82 aefead1805 feat: improve error handling in ImportCaddy and Dashboard components 2025-11-21 14:07:30 -05:00
Wikid82 50e4932148 feat: add current password verification for email updates and implement tasks layout with navigation 2025-11-21 13:37:19 -05:00
Wikid82 a00dea5419 feat: normalize email addresses to lowercase in user registration and profile updates
- Updated user registration and profile update handlers to convert email addresses to lowercase before saving to the database.
- Added integration tests to verify login functionality after email changes, ensuring case insensitivity.
- Introduced a new Account page to replace the Security page, consolidating user account management features.
- Removed the old Security page and updated routing in the Settings layout.
- Enhanced the SystemStatus component to provide user feedback on update availability.
- Added password change functionality in the Account page, allowing users to update their passwords securely.
2025-11-21 13:04:49 -05:00
Wikid82 4d1f92d909 feat: update docker-compose configuration for development environment and add debugging capabilities 2025-11-21 12:17:54 -05:00
Wikid82 c8822f61ef feat: enhance sidebar collapse functionality and improve layout header structure 2025-11-21 12:15:18 -05:00
Wikid82 5db59291f4 feat: improve setup page navigation logic to handle loading state and redirect based on authentication 2025-11-21 11:58:25 -05:00
Wikid82 b7aff5a944 feat: refactor release workflow to enhance frontend and backend builds, add Caddy build step, and streamline artifact handling 2025-11-21 11:50:48 -05:00
Wikid82 8eed365743 feat: enhance email validation in Setup and Security pages, add sidebar collapse functionality in Layout 2025-11-21 11:46:09 -05:00
Wikid82 8a0d7952a9 feat: add profile update functionality and integrate it into the Security page 2025-11-21 11:25:58 -05:00
Wikid82 9914e20817 feat: optimize Dockerfile build process with cache mounts for frontend and backend 2025-11-21 10:54:03 -05:00
Wikid82 2ec7adab43 feat: add PasswordStrengthMeter component and integrate it into Security and Setup pages 2025-11-21 10:49:42 -05:00
Jeremy 4551cfbd9b Merge pull request #173 from Wikid82/development
Propagate changes from development into feature/alpha-completion
2025-11-21 10:26:49 -05:00
Jeremy 9329b8dd8a Merge pull request #184 from Wikid82/renovate/docker-base-updates
chore(deps): update tonistiigi/xx docker tag to v1.8.0
2025-11-21 10:26:00 -05:00
Jeremy 27e2376bee Merge pull request #183 from Wikid82/renovate/golangci-golangci-lint-action-9.x
chore(deps): update golangci/golangci-lint-action action to v9.1.0
2025-11-21 10:25:36 -05:00
renovate[bot] 766075298c chore(deps): update tonistiigi/xx docker tag to v1.8.0 2025-11-21 15:24:14 +00:00
renovate[bot] 4dcab99ecf chore(deps): update golangci/golangci-lint-action action to v9.1.0 2025-11-21 15:24:09 +00:00
Wikid82 cdc8048590 fix: enhance Dockerfile for cross-compilation support and update Caddy build process 2025-11-21 10:22:54 -05:00
Wikid82 6ba87eb121 fix: rename workflow to Docker Build, Publish & Test and enhance image testing steps 2025-11-21 09:55:46 -05:00
Wikid82 4217279770 fix: enhance import session response structure and update preview query conditions 2025-11-21 09:41:58 -05:00
Jeremy de703fb90c Merge pull request #172 from Wikid82/development
Merge development into main
2025-11-21 00:02:57 -05:00
Wikid82 29e1523364 Add build and CodeQL scan scripts
- Created a build script to compile the Go backend.
- Added a CodeQL scan script to automate the creation and analysis of CodeQL databases for Go and JavaScript/TypeScript, including necessary checks for dependencies.
2025-11-20 23:59:56 -05:00
Wikid82 959f56eab6 fix: add CodeQL files to .gitignore to prevent unnecessary tracking 2025-11-20 23:34:54 -05:00
Wikid82 39f6cf9155 fix: enhance path sanitization in backup and log services to prevent directory traversal 2025-11-20 23:07:48 -05:00
Jeremy 6d03575ea6 Merge pull request #169 from Wikid82/feature/alpha-completion
Feature: Alpha Completion (Logging, Backups, Docker Management, System Settings)
2025-11-20 23:00:44 -05:00
Wikid82 3c32156339 chore: update codecov config on main 2025-11-20 23:00:22 -05:00
Wikid82 96cb7c8ef4 test: improve backend coverage for caddy, backup and import services 2025-11-20 22:56:11 -05:00
Wikid82 a2f1aaa6c2 ci: fix codeql permissions 2025-11-20 22:44:55 -05:00
Wikid82 9059760164 fix: update Docker socket volume to read-only for security 2025-11-20 22:37:46 -05:00
Wikid82 3ec2e47d78 test: improve frontend coverage and exclude backend docker services 2025-11-20 22:36:44 -05:00
Wikid82 62904858b2 refactor: reorganize imports and improve error handling across components 2025-11-20 22:21:32 -05:00
Wikid82 9f62a4a2df feat: add Docker container management functionality
- Implement DockerHandler to handle API requests for listing Docker containers.
- Create DockerService to interact with Docker API and retrieve container information.
- Add routes for Docker container management in the API.
- Introduce frontend API integration for Docker container listing.
- Enhance ProxyHostForm to allow quick selection of Docker containers.
- Update Docker-related tests to ensure functionality and error handling.
- Modify Docker Compose files to enable Docker socket access for local and remote environments.
- Add TypeScript configurations for improved build processes.
2025-11-20 21:27:02 -05:00
Jeremy 8c67e656b9 Merge pull request #168 from Wikid82/development
Propagate changes from development into feature/alpha-completion
2025-11-20 20:38:05 -05:00
Jeremy d7a0053a07 Merge pull request #167 from Wikid82/renovate/pin-dependencies
chore(deps): pin codecov/codecov-action action to 5a10915
2025-11-20 20:36:53 -05:00
renovate[bot] 057fa8d93b chore(deps): pin codecov/codecov-action action to 5a10915 2025-11-21 01:32:48 +00:00
Jeremy c282ed1ff2 Merge pull request #166 from Wikid82/development
Propagate changes from development into feature/alpha-completion
2025-11-20 20:32:19 -05:00
Jeremy 41234a52bf Merge branch 'feature/alpha-completion' into development 2025-11-20 20:32:10 -05:00
Wikid82 2eab570d54 Add comprehensive tests for services and middleware
- Implement tests for AuthMiddleware to handle cookie and token authentication.
- Create tests for the Importer and Manager in the Caddy package.
- Enhance AuthService tests with password change and token validation scenarios.
- Introduce tests for CertificateService to validate certificate listing and expiry.
- Expand LogService tests to cover log querying and pagination.
- Add NotificationService tests for creating, listing, and marking notifications as read.
- Implement ProxyHostService tests for CRUD operations and unique domain validation.
- Create RemoteServerService tests for CRUD operations.
- Add UpdateService tests to mock GitHub API responses for version checking.
- Introduce UptimeService tests to check host availability and notifications for down hosts.
2025-11-20 20:14:35 -05:00
Wikid82 3b18ae80f2 feat: add backend/coverage.txt to .gitignore 2025-11-20 20:08:20 -05:00
Wikid82 7a4c7eba25 feat: update go-test-coverage.sh to use coverage.txt and increase minimum coverage requirement 2025-11-20 20:07:56 -05:00
Wikid82 234427a195 feat: add fake_caddy.sh script for testing purposes 2025-11-20 20:06:57 -05:00
Wikid82 ff6847b2cd feat: enhance LogsHandler with query parameters for filtering and pagination, and add download functionality 2025-11-20 20:06:46 -05:00
Wikid82 a0cb0b49d4 docs: Finalize Docker deployment documentation (Issue #12) 2025-11-20 13:44:16 -05:00
Wikid82 20c8944380 feat: Complete Issue #11 - Fix backup UI bugs and implement System Settings page 2025-11-20 13:38:05 -05:00
Wikid82 042082fa87 docs: Add implementation summary for Issue #10 2025-11-20 13:20:06 -05:00
Wikid82 77e530ac20 feat: Implement backend support for advanced access logging 2025-11-20 13:19:09 -05:00
Wikid82 6db6652cd2 feat: Implement advanced access logging with Caddy JSON format, filtering, and download 2025-11-20 13:19:01 -05:00
Wikid82 28c04ff3aa feat: add date-fns dependency for date manipulation 2025-11-20 13:18:37 -05:00
Wikid82 dead29a585 feat: enhance Logs page with log filtering, pagination, and download functionality 2025-11-20 13:18:24 -05:00
Jeremy 4b450cf4c0 Merge pull request #165 from Wikid82/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2025-11-20 12:18:57 -05:00
Jeremy 8bc273ccac Merge pull request #164 from Wikid82/renovate/github.com-gin-gonic-gin-1.x
fix(deps): update module github.com/gin-gonic/gin to v1.11.0
2025-11-20 12:18:21 -05:00
Wikid82 e62eeebfba feat: implement Settings, Security, and Backups routes with API integration 2025-11-20 11:38:24 -05:00
Wikid82 113745aa03 feat: add NotificationCenter and SystemStatus components for improved user notifications and system updates 2025-11-20 11:38:15 -05:00
Wikid82 34a33c3a2e feat: enhance Layout tests with QueryClient integration and async version check 2025-11-20 11:38:05 -05:00
Wikid82 46b67d2414 feat: implement Backups, Security, and SettingsLayout pages with API integration and state management 2025-11-20 11:37:58 -05:00
renovate[bot] 0369e51918 chore(deps): update actions/checkout action to v6 2025-11-20 16:37:52 +00:00
Wikid82 4fcc0d6c4f feat: improve Go test coverage script by adding trap for coverage file cleanup and ensuring readonly module mode 2025-11-20 11:37:37 -05:00
Wikid82 56ab9486a4 feat: implement settings refactor, system status, notifications, and pre-commit hooks 2025-11-20 11:37:10 -05:00
Wikid82 20d25d49f3 feat: adjust minimum coverage threshold and refine test command for Go coverage script 2025-11-20 11:30:11 -05:00
Wikid82 5c5b4f71d2 feat: add Go Test Coverage hook to pre-commit configuration 2025-11-20 11:29:05 -05:00
Wikid82 7c2be5d037 feat: update access logging and Docker configuration tasks for improved functionality and deployment 2025-11-20 10:28:46 -05:00
Wikid82 5cb69ab55e feat: add Phase 8 summary for Alpha completion including Logging, Backups, and Docker configuration 2025-11-20 10:28:41 -05:00
Wikid82 79befed67a feat: add route for Logs page in the application 2025-11-20 10:28:35 -05:00
Wikid82 be5c6b92b0 feat: add Logs page with log viewing and backup management functionality 2025-11-20 10:28:28 -05:00
Wikid82 4d1077f030 feat: add 'Logs' navigation item to the layout 2025-11-20 10:28:19 -05:00
Wikid82 1a506a0b46 feat: add backups and logs API with corresponding interfaces and functions 2025-11-20 10:28:10 -05:00
Wikid82 6098df2d68 feat: add cron and lumberjack dependencies for scheduling and log rotation 2025-11-20 10:28:03 -05:00
Wikid82 12e74d1c84 feat: implement backup and log services with backup creation and log reading functionality 2025-11-20 10:27:55 -05:00
Wikid82 cf368f107c feat: implement logging configuration with access log setup 2025-11-20 10:27:45 -05:00
Wikid82 c63fa70414 feat: add backup and log routes with corresponding handlers 2025-11-20 10:27:37 -05:00
Wikid82 166b493629 feat: add backup and logs handlers with corresponding tests 2025-11-20 10:27:31 -05:00
Wikid82 556426841f feat: implement logging setup with rotation and fallback directory 2025-11-20 10:27:08 -05:00
renovate[bot] 0322605f45 fix(deps): update module github.com/gin-gonic/gin to v1.11.0 2025-11-20 14:56:05 +00:00
Wikid82 9d39241c61 feat: update health check API response and integrate versioning in Layout component 2025-11-20 09:54:55 -05:00
Jeremy cbfd1c08b2 Merge pull request #163 from Wikid82/renovate/npm-minorpatch
chore(deps): update npm minor/patch
2025-11-20 09:54:33 -05:00
renovate[bot] 299f2a1631 chore(deps): update npm minor/patch 2025-11-20 13:41:09 +00:00
Wikid82 f166007e3f fix: update RequireAuth component to accept ReactNode as children type 2025-11-20 01:22:48 -05:00
Wikid82 24711cbf28 fix: handle ForwardPort parsing error and update navigation expectation in Setup tests 2025-11-20 01:12:49 -05:00
Jeremy 2a584f35fd Merge pull request #162 from Wikid82/renovate/major-7-react-router-monorepo
fix(deps): update dependency react-router-dom to v7
2025-11-20 01:07:17 -05:00
renovate[bot] 0f510dff43 fix(deps): update dependency react-router-dom to v7 2025-11-20 06:06:48 +00:00
Jeremy 6452a8bbc2 Merge pull request #161 from Wikid82/renovate/major-19-react-monorepo
chore(deps): update react monorepo to v19 (major)
2025-11-20 01:06:01 -05:00
Wikid82 8420bcaad9 feat: update Dockerfile and docker-compose files for improved debugging and consistency; refactor Login and Setup components for setup status handling 2025-11-20 00:58:35 -05:00
Wikid82 0e64e96ffb feat: add Delve debugger support and update entrypoint for debugging 2025-11-20 00:09:20 -05:00
renovate[bot] 475998335c chore(deps): update react monorepo to v19 2025-11-20 05:02:06 +00:00
Wikid82 39de0a67ef feat: add Tailwind CSS support and refactor Layout component structure 2025-11-20 00:01:26 -05:00
Wikid82 ddc3d8ac1f refactor: remove unnecessary peer dependencies and update package versions in package-lock.json 2025-11-19 23:56:03 -05:00
Jeremy 01e5a2e79f Merge pull request #160 from Wikid82/renovate/npm-minorpatch
fix(deps): update npm minor/patch
2025-11-19 23:50:40 -05:00
Jeremy 64802da4aa Merge branch 'development' into renovate/npm-minorpatch 2025-11-19 23:50:32 -05:00
Jeremy 25a45cff73 Merge pull request #159 from Wikid82/renovate/major-19-react-monorepo
fix(deps): update react monorepo to v19 (major)
2025-11-19 23:49:50 -05:00
Jeremy 9060fe2ea6 Merge branch 'development' into renovate/major-19-react-monorepo 2025-11-19 23:49:41 -05:00
Jeremy cbd1632947 Merge pull request #158 from Wikid82/renovate/major-7-react-router-monorepo
fix(deps): update dependency react-router-dom to v7
2025-11-19 23:49:09 -05:00
Jeremy 197c6da41b Merge pull request #157 from Wikid82/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2
2025-11-19 23:48:58 -05:00
Jeremy 4ad58283a8 Merge pull request #156 from Wikid82/renovate/renovatebot-github-action-44.x
chore(deps): update renovatebot/github-action action to v44
2025-11-19 23:48:43 -05:00
renovate[bot] ba7431eae8 chore(deps): update renovatebot/github-action action to v44 2025-11-20 04:48:31 +00:00
Jeremy bb9b284403 Merge pull request #155 from Wikid82/renovate/renovatebot-github-action-43.x
chore(deps): update renovatebot/github-action action to v43
2025-11-19 23:48:08 -05:00
Jeremy 7e9d92ee19 Merge branch 'development' into renovate/renovatebot-github-action-43.x 2025-11-19 23:47:59 -05:00
renovate[bot] 0ca1083537 chore(deps): update renovatebot/github-action action to v43 2025-11-20 04:47:34 +00:00
Jeremy 85704f36d8 Merge pull request #154 from Wikid82/renovate/renovatebot-github-action-42.x
chore(deps): update renovatebot/github-action action to v42
2025-11-19 23:47:21 -05:00
Jeremy 4f935f1712 Merge branch 'development' into renovate/renovatebot-github-action-42.x 2025-11-19 23:47:14 -05:00
Jeremy 30d8615b0f Merge pull request #153 from Wikid82/renovate/renovatebot-github-action-41.x
chore(deps): update renovatebot/github-action action to v41
2025-11-19 23:46:54 -05:00
Jeremy f140c01822 Merge pull request #152 from Wikid82/renovate/golangci-golangci-lint-action-9.x
chore(deps): update golangci/golangci-lint-action action to v9
2025-11-19 23:46:39 -05:00
renovate[bot] baf61b21f3 chore(deps): update golangci/golangci-lint-action action to v9 2025-11-20 04:46:33 +00:00
Jeremy c82d9e71db Merge pull request #151 from Wikid82/renovate/golangci-golangci-lint-action-8.x
chore(deps): update golangci/golangci-lint-action action to v8
2025-11-19 23:46:07 -05:00
Jeremy d2b95e176c Merge branch 'development' into renovate/golangci-golangci-lint-action-8.x 2025-11-19 23:45:59 -05:00
renovate[bot] b895476a08 fix(deps): update npm minor/patch 2025-11-20 04:45:43 +00:00
Jeremy b8628f283f Merge pull request #150 from Wikid82/renovate/golangci-golangci-lint-action-7.x
chore(deps): update golangci/golangci-lint-action action to v7
2025-11-19 23:45:36 -05:00
Jeremy 5b77564eca Merge branch 'development' into renovate/golangci-golangci-lint-action-7.x 2025-11-19 23:45:27 -05:00
Jeremy 2efe776093 Merge pull request #149 from Wikid82/renovate/golangci-golangci-lint-action-6.x
chore(deps): update golangci/golangci-lint-action action to v6
2025-11-19 23:45:03 -05:00
Jeremy 4de7ab2a99 Merge pull request #148 from Wikid82/renovate/major-4-tailwindcss-monorepo
chore(deps): update dependency tailwindcss to v4
2025-11-19 23:44:47 -05:00
Jeremy ddeb51efc0 Merge pull request #147 from Wikid82/renovate/major-7-react-monorepo
chore(deps): update dependency eslint-plugin-react-hooks to v7
2025-11-19 23:44:32 -05:00
Jeremy 5028a0ce5a Merge pull request #145 from Wikid82/renovate/actions-github-script-8.x
chore(deps): update actions/github-script action to v8
2025-11-19 23:44:10 -05:00
Jeremy 19e481876d Merge branch 'development' into renovate/actions-github-script-8.x 2025-11-19 23:44:02 -05:00
Jeremy 45c67909ce Merge pull request #144 from Wikid82/renovate/actions-github-script-7.x
chore(deps): update actions/github-script action to v7
2025-11-19 23:43:40 -05:00
Jeremy 820f06a9ad Merge branch 'development' into renovate/actions-github-script-7.x 2025-11-19 23:43:27 -05:00
renovate[bot] c60beec504 fix(deps): update react monorepo to v19 2025-11-20 04:43:20 +00:00
renovate[bot] 2c8b8a2745 fix(deps): update dependency react-router-dom to v7 2025-11-20 04:43:13 +00:00
renovate[bot] 450c45bf3d chore(deps): update softprops/action-gh-release action to v2 2025-11-20 04:43:05 +00:00
Jeremy 3c43429eb8 Merge pull request #146 from Wikid82/renovate/actions-setup-node-6.x
chore(deps): update actions/setup-node action to v6
2025-11-19 23:42:56 -05:00
renovate[bot] b0739198aa chore(deps): update renovatebot/github-action action to v42 2025-11-20 04:42:49 +00:00
renovate[bot] e66be81629 chore(deps): update renovatebot/github-action action to v41 2025-11-20 04:42:44 +00:00
renovate[bot] 67fb075f9e chore(deps): update golangci/golangci-lint-action action to v8 2025-11-20 04:42:35 +00:00
renovate[bot] 52aa96ec96 chore(deps): update golangci/golangci-lint-action action to v7 2025-11-20 04:42:31 +00:00
renovate[bot] 8f7b2ac657 chore(deps): update golangci/golangci-lint-action action to v6 2025-11-20 04:42:26 +00:00
Jeremy e6826a440b Merge pull request #143 from Wikid82/renovate/github.com-gin-gonic-gin-1.x
fix(deps): update module github.com/gin-gonic/gin to v1.11.0
2025-11-19 23:42:26 -05:00
renovate[bot] f9b9a7ff64 chore(deps): update dependency tailwindcss to v4 2025-11-20 04:42:22 +00:00
renovate[bot] 113ff3db4d chore(deps): update dependency eslint-plugin-react-hooks to v7 2025-11-20 04:42:14 +00:00
renovate[bot] 83e700b783 chore(deps): update actions/setup-node action to v6 2025-11-20 04:42:02 +00:00
Jeremy 12e16c6f62 Merge pull request #142 from Wikid82/renovate/go-1.x
chore(deps): update dependency go to v1.25.4
2025-11-19 23:42:01 -05:00
renovate[bot] 588c82a5a8 chore(deps): update actions/github-script action to v8 2025-11-20 04:41:58 +00:00
renovate[bot] 7c01d04002 chore(deps): update actions/github-script action to v7 2025-11-20 04:41:54 +00:00
renovate[bot] e44359f5ed fix(deps): update module github.com/gin-gonic/gin to v1.11.0 2025-11-20 04:41:50 +00:00
Jeremy bf3d782298 Merge pull request #141 from Wikid82/renovate/pin-dependencies
chore(deps): pin actions/github-script action to d7906e4
2025-11-19 23:41:42 -05:00
renovate[bot] 7fab85d5a1 chore(deps): update dependency go to v1.25.4 2025-11-20 04:41:32 +00:00
renovate[bot] abfde67183 chore(deps): pin actions/github-script action to d7906e4 2025-11-20 04:41:13 +00:00
Wikid82 2533ee1710 feat: add workflow for pruning Renovate branches 2025-11-19 23:38:47 -05:00
Jeremy 75db67aa3b Merge pull request #139 from Wikid82/renovate/golangci-golangci-lint-action-5.x
chore(deps): update golangci/golangci-lint-action action to v5 - abandoned
2025-11-19 23:38:22 -05:00
Jeremy 4364f102f0 Merge branch 'development' into renovate/golangci-golangci-lint-action-5.x 2025-11-19 23:38:09 -05:00
Jeremy 4ed496f536 Merge pull request #140 from Wikid82/renovate/golangci-golangci-lint-action-6.x
chore(deps): update golangci/golangci-lint-action action to v6
2025-11-19 23:31:24 -05:00
Jeremy f7bbce10e8 Merge pull request #138 from Wikid82/renovate/github-codeql-action-4.x
chore(deps): update github/codeql-action action to v4
2025-11-19 23:31:04 -05:00
Jeremy bd995d1baa Merge pull request #137 from Wikid82/renovate/docker-build-push-action-6.x
chore(deps): update docker/build-push-action action to v6
2025-11-19 23:30:49 -05:00
Jeremy 787fcf2ddc Merge pull request #135 from Wikid82/renovate/vite-7.x
chore(deps): update dependency vite to v7
2025-11-19 23:30:08 -05:00
renovate[bot] 876d2b2e30 chore(deps): update actions/setup-go action to v6.1.0 (#136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 04:29:45 +00:00
Jeremy 8fe7207ac0 Merge pull request #132 from Wikid82/renovate/node-24.x
chore(deps): update dependency node to v24
2025-11-19 23:29:30 -05:00
Jeremy 121c06f4ed Merge branch 'development' into renovate/node-24.x 2025-11-19 23:29:20 -05:00
renovate[bot] 8de9a995cf chore(deps): update golangci/golangci-lint-action action to v6 2025-11-20 04:28:57 +00:00
renovate[bot] 8562f3fc12 chore(deps): update golangci/golangci-lint-action action to v5 2025-11-20 04:28:52 +00:00
renovate[bot] f09cb19108 chore(deps): update github/codeql-action action to v4 2025-11-20 04:28:47 +00:00
renovate[bot] 36e4730a2f chore(deps): update docker/build-push-action action to v6 2025-11-20 04:28:42 +00:00
Jeremy 9b53302263 Merge pull request #131 from Wikid82/renovate/node-22.x
chore(deps): update dependency node to v22
2025-11-19 23:27:17 -05:00
Wikid82 7706b01edb feat: implement logout functionality in Layout component and enhance AuthContext with auto-logout feature 2025-11-19 23:26:08 -05:00
Wikid82 c97c16a752 feat: add Settings and Setup pages for user management
- Implemented Settings page for changing user passwords with validation and feedback.
- Created Setup page for initial admin account setup with form handling and navigation.
- Added API service layer for handling requests related to proxy hosts, remote servers, and import functionality.
- Introduced mock data for testing purposes and set up testing framework with vitest.
- Configured Tailwind CSS for styling and Vite for development and build processes.
- Added scripts for Dockerfile validation, Python syntax checking, and Sourcery integration.
- Implemented release and coverage scripts for better CI/CD practices.
2025-11-19 22:54:35 -05:00
Wikid82 6471e24f11 feat: Add GitHub Actions workflows for Docker build, publish, documentation deployment, and quality checks
- Implemented `docker-build.yml` for building and pushing Docker images with multi-platform support, Trivy security scanning, and conditional builds based on commit messages.
- Created `docker-publish.yml` for streamlined Docker image publishing with Trivy vulnerability scanning on push events.
- Added `docs.yml` to automate documentation deployment to GitHub Pages, including a custom HTML structure and markdown conversion.
- Introduced `propagate-changes.yml` to automate PR creation for synchronizing changes between main, development, and feature branches.
- Established `quality-checks.yml` for running backend (Go) and frontend (React) quality checks, including tests and linting.
- Developed `release.yml` for generating changelogs and creating GitHub releases upon version tag pushes.
- Set up `renovate.yml` for automated dependency updates on a daily schedule.
2025-11-19 22:53:42 -05:00
Wikid82 1e2d87755d Remove Settings and Setup pages along with their tests and related API services
- Deleted Settings.tsx and Setup.tsx pages, which included functionality for changing passwords and setting up an admin account.
- Removed associated test files for Setup page.
- Eliminated API service definitions related to proxy hosts, remote servers, import functionality, and health checks.
- Cleaned up mock data and test setup files.
- Removed configuration files for TypeScript, Vite, and Tailwind CSS.
- Deleted scripts for testing coverage, release management, Dockerfile validation, and Python compilation checks.
- Removed Sourcery pre-commit wrapper script.
2025-11-19 22:53:32 -05:00
Wikid82 1bc6be10a1 fix(ci): add docker login to test-image job in docker-build workflow 2025-11-19 22:38:45 -05:00
Wikid82 11aab568da fix: update script and stylesheet references in index.html; modify conflicts prop in ImportReviewTable to use keys 2025-11-19 22:17:59 -05:00
renovate[bot] 942fa9b196 chore(deps): update dependency vite to v7 2025-11-20 03:16:00 +00:00
renovate[bot] 238b04da5d chore(deps): update dependency node to v24 2025-11-20 03:15:07 +00:00
renovate[bot] ab2380488e chore(deps): update dependency node to v22 2025-11-20 03:14:45 +00:00
Wikid82 e17c25693c docs: update required Go version to 1.24 across docs 2025-11-19 22:02:32 -05:00
Wikid82 d10911bfbc docs: update required Go version to 1.24 across docs 2025-11-19 22:01:43 -05:00
Wikid82 610679aa0d ci: use Go 1.24 in quality-checks workflow to match backend/go.mod 2025-11-19 21:58:28 -05:00
Jeremy bf84a544e5 Merge pull request #130 from Wikid82/renovate/node-20.x
chore(deps): update dependency node to v20.19.5
2025-11-19 21:53:27 -05:00
Jeremy 9b8915353c Merge pull request #128 from Wikid82/renovate/major-6-react-monorepo
chore(deps): update dependency eslint-plugin-react-hooks to v6
2025-11-19 21:53:00 -05:00
Jeremy 7fe225f680 Merge pull request #127 from Wikid82/renovate/vitejs-plugin-react-5.x
chore(deps): update dependency @vitejs/plugin-react to v5
2025-11-19 21:52:39 -05:00
Jeremy 09340b1e59 Merge pull request #126 from Wikid82/renovate/actions-upload-pages-artifact-4.x
chore(deps): update actions/upload-pages-artifact action to v4
2025-11-19 21:52:21 -05:00
Wikid82 4f17dad816 fix: downgrade Go version to 1.24.0 and update toolchain 2025-11-19 21:50:30 -05:00
renovate[bot] a4aab3a428 chore(deps): update dependency node to v20.19.5 2025-11-20 02:48:40 +00:00
renovate[bot] 35c6d16f80 chore(deps): update dependency eslint-plugin-react-hooks to v6 2025-11-20 02:48:29 +00:00
renovate[bot] 9a0796109c chore(deps): update dependency @vitejs/plugin-react to v5 2025-11-20 02:48:22 +00:00
renovate[bot] 7cf2b1276f chore(deps): update actions/upload-pages-artifact action to v4 2025-11-20 02:48:09 +00:00
Wikid82 d6bfb0c3c9 chore: update gitignore and restore backend files
- Add backend/node_modules to .gitignore
- Untrack backend/node_modules from git
- Restore backend source files (go.mod, main.go, etc.) that were missing in HEAD
- Include frontend test updates
2025-11-19 21:45:21 -05:00
Wikid82 f7ce1df847 feat: add TypeScript definitions for Babel parser 2025-11-19 21:40:24 -05:00
Wikid82 9c7ef43851 feat: add LICENSE, README, and package.json for @babel/helper-validator-identifier 2025-11-19 21:39:18 -05:00
Wikid82 0af08ee627 feat: add babel-parser script for parsing files and generating AST 2025-11-19 21:38:58 -05:00
Wikid82 685a6c810a feat: add helper functions for identifier and keyword validation
- Introduced `index.js` and `index.js.map` to export functions for checking identifier characters and names.
- Added `keyword.js` and `keyword.js.map` to implement functions for validating keywords and reserved words in JavaScript.
- Implemented various checks for strict and non-strict reserved words, including binding-specific checks.
2025-11-19 21:38:33 -05:00
Wikid82 6d449f33c3 feat: Add LICENSE and README files for @babel/helper-string-parser package 2025-11-19 21:38:07 -05:00
Wikid82 f0789dd8af Add helper functions for string parsing in Babel
- Introduced `readStringContents`, `readEscapedChar`, `readHexChar`, and `readCodePoint` functions to enhance string parsing capabilities.
- Implemented error handling for unterminated strings and invalid escape sequences.
- Added support for numeric separators in different numeral systems (binary, octal, decimal, hexadecimal).
- Included source maps for better debugging and traceability.
2025-11-19 21:37:43 -05:00
Wikid82 c510830181 feat: Add symlinks for various CLI tools in node_modules 2025-11-19 21:37:33 -05:00
Wikid82 d93779cb37 feat: Add versioning information and Full function for application version string 2025-11-19 21:37:25 -05:00
Wikid82 4662d00643 feat: Add services for authentication, certificate management, proxy host, and remote server 2025-11-19 21:37:14 -05:00
Wikid82 fd6987e4a1 feat: Implement NewRouter function for serving frontend static files 2025-11-19 21:37:06 -05:00
Wikid82 619c6f2129 feat: Add model definitions for access control, proxy configuration, and user management 2025-11-19 21:36:58 -05:00
Wikid82 7e0b2abad0 feat: Implement SQLite database connection functionality 2025-11-19 21:36:50 -05:00
Wikid82 47edce1eae feat: Add configuration management with environment variable support 2025-11-19 21:36:26 -05:00
Wikid82 8919fc7655 feat: Implement Caddy configuration management, including client, validation, and import functionality 2025-11-19 21:36:15 -05:00
Wikid82 bf089c2d09 feat: Add database seeding functionality for remote servers, proxy hosts, settings, and default admin user 2025-11-19 21:36:06 -05:00
Wikid82 67e7721a9d feat: Add guideline for updating ignore files when creating new types or artifacts 2025-11-19 21:35:31 -05:00
Wikid82 304561303a feat: Implement certificate management handler and routes
- Added CertificateHandler for listing certificates.
- Created health check handler for service metadata.
- Introduced ImportHandler for handling Caddyfile imports.
- Developed ProxyHostHandler for CRUD operations on proxy hosts.
- Added RemoteServerHandler for managing remote servers.
- Implemented UserHandler for initial setup and user management.
- Created authentication middleware for secure API access.
- Registered all handlers and routes in the main API router.
- Added tests for proxy host and remote server handlers.
2025-11-19 21:35:20 -05:00
Wikid82 5e8f79b8dd feat: implement main application logic for backend server 2025-11-19 21:34:52 -05:00
Wikid82 512e6f858f refactor: remove unused Caddy configuration and validation code
- Deleted Caddy configuration types and validation logic from the backend.
- Removed associated test files for validation.
- Cleaned up related service and model files that are no longer needed.
- Removed unnecessary environment configuration and database connection code.
- Eliminated unused models related to access control, SSL certificates, and user management.
- Cleaned up node modules by removing unnecessary binaries.
2025-11-19 21:32:24 -05:00
Wikid82 240e883578 chore: Remove Black and Ruff pre-commit hooks from configuration 2025-11-19 21:31:41 -05:00
Wikid82 4a63eaaec8 feat: Add symlinks for various CLI tools in node_modules/.bin 2025-11-19 21:17:50 -05:00
Wikid82 906d15fc9b test: Add CRUD tests for RemoteServer and ProxyHost handlers 2025-11-19 21:17:39 -05:00
Wikid82 1f83bc1e3e docs: Add testing requirement for new code to include unit tests 2025-11-19 21:15:35 -05:00
Wikid82 4ad526f185 fix: Update getImportStatus and commitImport mocks to handle session state correctly 2025-11-19 20:50:23 -05:00
Wikid82 24d5cf3954 refactor: Update mutation functions in useImport, useProxyHosts, and useRemoteServers hooks for improved type handling 2025-11-19 20:47:55 -05:00
Wikid82 e13223592c feat: Add testRemoteServerConnection functionality to useRemoteServers hook and update related tests 2025-11-19 20:33:06 -05:00
Wikid82 6f289d3fc7 fix: Update API mock path for useProxyHosts tests to ensure correct functionality 2025-11-19 20:23:21 -05:00
Wikid82 21eb2d2608 refactor: remove old test files for useProxyHosts and useRemoteServers; add new tests for useImport and update existing tests for useProxyHosts and useRemoteServers 2025-11-19 20:22:01 -05:00
Wikid82 2f0058083e fix: Rename cancel button text to 'Back' in ImportReviewTable test 2025-11-19 20:10:41 -05:00
Wikid82 a79fcee928 fix: Update error message in ImportReviewTable test for clarity 2025-11-19 20:10:41 -05:00
Wikid82 e6fb423d6b fix: Correct render function call in ProxyHostForm test 2025-11-19 20:10:40 -05:00
Wikid82 3c5bdf0a0b feat: Update ImportReviewTable and RemoteServerForm components for improved conflict handling and connection testing 2025-11-19 20:10:40 -05:00
Jeremy 04f94d47ff Merge pull request #124 from Wikid82/renovate/actions-setup-node-5.x
chore(deps): update actions/setup-node action to v5
2025-11-19 20:05:09 -05:00
Jeremy c70b749826 Merge pull request #123 from Wikid82/renovate/actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6
2025-11-19 20:04:52 -05:00
Jeremy dd467f7bf1 Merge pull request #122 from Wikid82/renovate/actions-github-script-8.x
chore(deps): update actions/github-script action to v8
2025-11-19 20:04:30 -05:00
Jeremy 8877acf44c Merge pull request #121 from Wikid82/renovate/golang.org-x-crypto-0.x
fix(deps): update module golang.org/x/crypto to v0.45.0
2025-11-19 20:04:11 -05:00
renovate[bot] d7af175151 chore(deps): update actions/setup-node action to v5 2025-11-20 01:00:54 +00:00
renovate[bot] fe22be1571 chore(deps): update actions/setup-go action to v6 2025-11-20 01:00:50 +00:00
renovate[bot] 6a075bf7ff chore(deps): update actions/github-script action to v8 2025-11-20 01:00:45 +00:00
renovate[bot] b95c353c97 fix(deps): update module golang.org/x/crypto to v0.45.0 2025-11-20 01:00:40 +00:00
Wikid82 00981be8dc fix: CI workflow shell injection vulnerability
- Use environment variables for GitHub context in shell scripts to prevent injection attacks and syntax errors when commit messages contain special characters (e.g. single quotes, ampersands).
- Fixes failure when merging branches with special characters in their names.
2025-11-19 19:59:45 -05:00
Wikid82 8f35d08dfa Merge branch 'feature/Automatic-HTTPS-&-Certificate-Management' into development 2025-11-19 19:57:46 -05:00
Wikid82 70a28e53ee feat: Add Change Password functionality 2025-11-19 19:57:38 -05:00
Jeremy 656b32985e Merge pull request #119 from Wikid82/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5
2025-11-19 19:56:23 -05:00
Jeremy 3ba330a0c7 Merge pull request #118 from Wikid82/renovate/actions-add-to-project-1.x
chore(deps): update actions/add-to-project action to v1
2025-11-19 19:56:04 -05:00
Jeremy abe44d56d9 Merge pull request #117 from Wikid82/renovate/gorm.io-gorm-1.x
fix(deps): update module gorm.io/gorm to v1.31.1
2025-11-19 19:55:43 -05:00
renovate[bot] dbc3df904b fix(deps): update module gorm.io/gorm to v1.31.1 2025-11-20 00:49:24 +00:00
Jeremy e529fcbb83 Merge pull request #116 from Wikid82/renovate/gorm.io-driver-sqlite-1.x
fix(deps): update module gorm.io/driver/sqlite to v1.6.0
2025-11-19 19:48:02 -05:00
Jeremy 84b4fc2e6f Merge pull request #120 from Wikid82/feature/Automatic-HTTPS-&-Certificate-Management
feat: Implement User Authentication & Fix Frontend Startup
2025-11-19 19:47:13 -05:00
renovate[bot] 17672c2b53 chore(deps): update actions/checkout action to v5 2025-11-20 00:45:05 +00:00
renovate[bot] 053760c0f1 chore(deps): update actions/add-to-project action to v1 2025-11-20 00:44:58 +00:00
renovate[bot] 7120678910 fix(deps): update module gorm.io/driver/sqlite to v1.6.0 2025-11-20 00:44:44 +00:00
Wikid82 945b18ab3e feat: Implement User Authentication and Fix Frontend Startup
- Implemented Issue #9: User Authentication & Authorization
  - Added User model fields (FailedLoginAttempts, LockedUntil, LastLogin)
  - Created AuthService with JWT support, bcrypt hashing, and account lockout
  - Added AuthMiddleware and AuthHandler
  - Registered auth routes in backend
  - Created AuthContext and RequireAuth component in frontend
  - Implemented Login page and integrated with backend
- Fixed 'Blank Page' issue in local Docker environment
  - Added QueryClientProvider to main.tsx
  - Installed missing lucide-react dependency
  - Fixed TypeScript linting errors in SetupGuard.tsx
- Updated docker-entrypoint.sh to use 127.0.0.1 for reliable Caddy checks
- Verified with local Docker build
2025-11-19 19:44:22 -05:00
Wikid82 f92827db67 feat(tests): add ThemeProvider to Layout component tests 2025-11-19 18:33:11 -05:00
Jeremy e8de9b1a2c Merge pull request #106 from Wikid82/renovate/aquasecurity-trivy-action-0.x
chore(deps): update aquasecurity/trivy-action action to v0.33.1
2025-11-19 18:20:54 -05:00
Jeremy 90f3342763 Merge pull request #108 from Wikid82/renovate/node-18.x
chore(deps): update dependency node to v18.20.8
2025-11-19 18:20:16 -05:00
Jeremy 9037ea6f86 Merge pull request #110 from Wikid82/renovate/docker-build-push-action-5.x
chore(deps): update docker/build-push-action action to v5.4.0
2025-11-19 18:20:01 -05:00
Jeremy a5bf51b62c Merge pull request #111 from Wikid82/renovate/docker-login-action-3.x
chore(deps): update docker/login-action action to v3.6.0
2025-11-19 18:18:40 -05:00
Jeremy 9c680584c6 Merge pull request #112 from Wikid82/renovate/docker-setup-buildx-action-3.x
chore(deps): update docker/setup-buildx-action action to v3.11.1
2025-11-19 18:18:19 -05:00
Jeremy fc8be94198 Merge pull request #113 from Wikid82/renovate/docker-setup-qemu-action-3.x
chore(deps): update docker/setup-qemu-action action to v3.7.0
2025-11-19 18:17:59 -05:00
Jeremy 26f0a66681 Merge pull request #114 from Wikid82/renovate/renovatebot-github-action-40.x
chore(deps): update renovatebot/github-action action to v40.3.6
2025-11-19 18:17:37 -05:00
Jeremy 04c40a4cc4 Merge pull request #105 from Wikid82/renovate/actions-setup-node-4.x
chore(deps): update actions/setup-node action to v4.4.0
2025-11-19 18:17:22 -05:00
Jeremy c38a027046 Merge pull request #102 from Wikid82/renovate/golangci-golangci-lint-action-4.x
chore(deps): update golangci/golangci-lint-action action to v4.0.1
2025-11-19 18:16:15 -05:00
renovate[bot] 7f14ff0735 chore(deps): update renovatebot/github-action action to v40.3.6 2025-11-19 23:16:01 +00:00
renovate[bot] c1f84162ad chore(deps): update docker/setup-qemu-action action to v3.7.0 2025-11-19 23:15:57 +00:00
renovate[bot] 8a68cb19cf chore(deps): update docker/setup-buildx-action action to v3.11.1 2025-11-19 23:15:53 +00:00
renovate[bot] 9d99926b8e chore(deps): update docker/login-action action to v3.6.0 2025-11-19 23:15:48 +00:00
renovate[bot] ecf2feb857 chore(deps): update docker/build-push-action action to v5.4.0 2025-11-19 23:15:44 +00:00
Jeremy 4175993e1f Merge pull request #100 from Wikid82/renovate/pin-dependencies
chore(deps): pin dependencies
2025-11-19 18:15:37 -05:00
Jeremy aa4753ebda Merge pull request #109 from Wikid82/feature/prox_host_managment
Refactor: Migrate Frontend to React Query & Clean Architecture
2025-11-19 18:14:45 -05:00
Wikid82 50d710ba7b chore: rename project artifacts to CPMP 2025-11-19 18:07:36 -05:00
Wikid82 55e85db1d8 docs: update copilot instructions with strict architecture rules 2025-11-19 17:51:17 -05:00
renovate[bot] db1e71604d chore(deps): update dependency node to v18.20.8 2025-11-19 22:47:20 +00:00
renovate[bot] c05dd54f5c chore(deps): update aquasecurity/trivy-action action to v0.33.1 2025-11-19 22:47:08 +00:00
renovate[bot] a0c0456d0d chore(deps): update actions/setup-node action to v4.4.0 2025-11-19 22:47:03 +00:00
Wikid82 c0c0c29f9f refactor(frontend): consolidate architecture, migrate to React Query, and remove legacy files 2025-11-19 17:46:11 -05:00
renovate[bot] a3626a70bc chore(deps): update golangci/golangci-lint-action action to v4.0.1 2025-11-19 20:31:55 +00:00
renovate[bot] f7ac6297e3 chore(deps): pin dependencies 2025-11-19 20:31:40 +00:00
Jeremy 64485f0206 Merge pull request #99 from Wikid82/development
Propagate changes from development into feature/prox_host_managment
2025-11-19 11:54:15 -05:00
Jeremy 6dec8a0aae Merge pull request #97 from Wikid82/renovate/actions-setup-go-5.x
chore(deps): update actions/setup-go action to v5.5.0
2025-11-19 11:52:57 -05:00
Jeremy 6922096438 Merge pull request #96 from Wikid82/renovate/actions-checkout-4.x
chore(deps): update actions/checkout action to v4.3.1
2025-11-19 11:52:42 -05:00
Jeremy 5cb289a26e Merge pull request #95 from Wikid82/renovate/golangci-golangci-lint-action-4.x
chore(deps): update golangci/golangci-lint-action action to v4.0.1
2025-11-19 11:52:24 -05:00
Jeremy a28dec399e Merge pull request #94 from Wikid82/renovate/actions-checkout-digest
chore(deps): update actions/checkout digest to 34e1148
2025-11-19 11:52:09 -05:00
Jeremy b4789aca68 Merge pull request #93 from Wikid82/renovate/pin-dependencies
chore(deps): pin dependencies
2025-11-19 11:51:52 -05:00
Jeremy c3c32d7b4d Merge pull request #98 from Wikid82/feature/prox_host_managment
Implement Issue #7: Core Proxy Host Management
2025-11-19 11:51:04 -05:00
renovate[bot] ba4fb99f0a chore(deps): update actions/setup-go action to v5.5.0 2025-11-19 16:47:48 +00:00
renovate[bot] 3e3089b8d6 chore(deps): update actions/checkout action to v4.3.1 2025-11-19 16:47:42 +00:00
renovate[bot] d7c067385e chore(deps): update golangci/golangci-lint-action action to v4.0.1 2025-11-19 16:47:37 +00:00
renovate[bot] 0474c83782 chore(deps): update actions/checkout digest to 34e1148 2025-11-19 16:47:32 +00:00
renovate[bot] e5b9ae8a90 chore(deps): pin dependencies 2025-11-19 16:47:27 +00:00
Wikid82 90ba956d97 feat: add custom locations management to ProxyHostForm
- Updated ProxyHostForm to include functionality for managing custom locations.
- Introduced add, remove, and update operations for locations in the form.
- Modified the ProxyHost interface to include an array of locations.
- Removed the advanced configuration textarea in favor of a more structured location input.
- Updated the frontend assets in index.html to reflect the latest build.
2025-11-19 11:46:26 -05:00
Wikid82 d559a24c45 feat(frontend): implement basic UI foundation (Issue #6) 2025-11-19 10:53:55 -05:00
Wikid82 9f74367ae6 fix(frontend): fix linting configuration and dependencies 2025-11-19 10:28:57 -05:00
Wikid82 3537dc0084 ci: add GitHub Actions workflow for building and pushing Docker images 2025-11-19 10:19:15 -05:00
Wikid82 b297585df3 ci: fix propagation logic (main->dev, dev->feature/**) 2025-11-19 10:14:21 -05:00
Wikid82 7ca6d8f606 ci: streamline workflows, consolidate docker build/publish, add quality checks 2025-11-19 10:13:41 -05:00
renovate[bot] 6f98ae794a chore(deps): update dependency isort to >=5.13.2 2025-11-19 10:10:26 -05:00
renovate[bot] 4ef17f487a chore(deps): update dependency go to v1.25.4 2025-11-19 10:07:19 -05:00
renovate[bot] 193e7e2f25 chore(deps): update dependency fastapi to >=0.121.2 2025-11-19 10:07:19 -05:00
renovate[bot] 38adf58b96 chore(deps): update dependency coverage to >=7.12.0 2025-11-19 10:07:18 -05:00
Wikid82 de484db648 ci(secrets): migrate GITHUB_TOKEN references to PROJECT_TOKEN secret 2025-11-19 10:07:18 -05:00
Wikid82 c055a7676f fix(ci): use ADD_TO_PROJECT_PAT for project access (user projects require PAT) 2025-11-19 10:07:18 -05:00
Wikid82 50eefbda08 fix(hooks): use python3 fallback in compile check script 2025-11-19 10:06:21 -05:00
Wikid82 069e8112c3 fix(ci): correct indentation for skip summary step in docker-build 2025-11-19 10:03:45 -05:00
Wikid82 1cf07a892a ci: skip heavy docker build/publish for renovate bot and chore(deps)/chore commits 2025-11-19 10:03:40 -05:00
Wikid82 7253dd4f5b fix(runtime): remove invalid Caddy --adapter json; use native JSON config 2025-11-19 10:03:16 -05:00
Wikid82 939847e6af ci: robust health check with retries; normalize IMAGE_NAME in publish workflow 2025-11-19 10:03:15 -05:00
Wikid82 6bf9fba474 chore(ci): skip add-to-project when PROJECT_URL secret absent 2025-11-19 10:02:47 -05:00
Wikid82 0d6fb54370 fix(ci): normalize IMAGE_NAME to lowercase for registry compliance 2025-11-19 10:02:20 -05:00
Wikid82 eec702a335 fix(ci): lowercase IMAGE_NAME in workflows; fix Renovate packageRule (remove matchUpdateTypes from allowedVersions rule) 2025-11-19 09:58:32 -05:00
renovate[bot] 65740e941b chore(deps): update dependency sqlalchemy to >=2.0.44 2025-11-19 09:58:23 -05:00
renovate[bot] b78c87ccbd chore(deps): update dependency python-multipart to >=0.0.20 2025-11-19 09:55:05 -05:00
renovate[bot] 77b8b0cee3 chore(deps): update dependency pytest to >=7.4.4 2025-11-19 09:55:05 -05:00
renovate[bot] 6761f0f8f0 chore(deps): update dependency passlib to >=1.7.4 2025-11-19 09:54:56 -05:00
Wikid82 59202c172b chore(ci): fix Renovate config and workflow; ensure checkout and valid packageRules 2025-11-19 09:54:14 -05:00
Wikid82 e3e6c75354 ci: add checkout step to Renovate workflow so config file is available 2025-11-19 09:54:13 -05:00
Wikid82 54881764ad chore(deps): bump golang.org/x/crypto to v0.31.0 (fix CVE-2024-45337) 2025-11-19 09:54:13 -05:00
Wikid82 ae4f03e26e CI: Show Trivy table output before SARIF upload; always upload SARIF; fail late on CRITICAL/HIGH 2025-11-19 09:53:57 -05:00
Wikid82 778854473a CI: Add Renovate automation and Caddy v3 monitor; fix CADDY_IMAGE ARG scope 2025-11-19 09:52:10 -05:00
Wikid82 3111421b92 CI: Pin Caddy base by digest in builds; enforce Trivy fail on CRITICAL/HIGH; pass CADDY_IMAGE build-arg 2025-11-19 09:52:10 -05:00
Wikid82 e3134a2ad7 Reduce Trivy findings: drop bash, switch to /bin/sh entrypoint, pin to caddy:2-alpine, upgrade runtime packages 2025-11-19 09:52:10 -05:00
Wikid82 d65b55144d CI: Generate PR tag via ref_name; skip push & security scan on PR 2025-11-19 09:52:09 -05:00
Wikid82 ffa1bd9af7 CI: Fix Trivy scan refs and guard SARIF upload; use metadata tags for docker-build scan; add outcome gating 2025-11-19 09:52:09 -05:00
Wikid82 8434121dd1 CI: Add CodeQL analysis workflow with security-events permissions and skip fork PRs 2025-11-19 09:52:08 -05:00
Wikid82 37cef06634 Docker: build frontend on BUILDPLATFORM to avoid arm64 Rollup native module; keep Rollup native skipped 2025-11-19 09:52:08 -05:00
Wikid82 f2ea164717 fix(docker): set ROLLUP_SKIP_NODEJS_NATIVE=1 to avoid arm64 native Rollup error 2025-11-19 09:52:08 -05:00
Wikid82 f14b0f3978 fix: Remove SHA-based Docker tags causing invalid format errors
- Remove type=sha tags that were producing invalid formats like ':-f0d0654'
- Keep latest/dev tags for branches and semver tags for releases
- SHA is still tracked via image digest and labels
2025-11-19 09:52:07 -05:00
Wikid82 8716919e22 fix: Correct Docker tag format in metadata action
- Add format=short to SHA tags to prevent malformed tags
- Standardize dev tag naming across workflows (was 'development')
- Ensure branch name is properly included in SHA prefix
2025-11-19 09:52:07 -05:00
Wikid82 95823204a6 feat: Add Trivy security scanning to Docker workflows
- Add Trivy vulnerability scanner after image build
- Upload SARIF results to GitHub Security tab
- Display critical/high severity issues in workflow logs
- Add security-events permission for SARIF upload
- Skip scanning on pull requests to save time
2025-11-19 09:51:58 -05:00
Wikid82 c979e3d417 chore: test workflows after enabling all actions 2025-11-19 09:51:28 -05:00
Wikid82 46d14cd827 chore: trigger workflows with updated action permissions 2025-11-19 09:51:28 -05:00
Wikid82 6273aa32a1 fix: Remove legacy Python lint job from CI workflow
- Remove ruff/flake8/pre-commit steps (legacy Python scaffold)
- Remove needs dependencies on non-existent lint job
- Backend and frontend tests now run independently
2025-11-19 09:50:59 -05:00
Wikid82 8e641b01bd fix: Eliminate act() warnings in frontend tests and resolve Docker arm64 build
- Wrap async mutations in act() across useImport, useProxyHosts, useRemoteServers tests
- Add npm_config_rollup_skip_nodejs_native=1 in Dockerfile to bypass native binary for arm64
- Enable Go module caching in CI via cache-dependency-path on setup-go action
- All frontend tests now pass cleanly (49/49) without React Testing Library warnings
2025-11-19 09:50:08 -05:00
Wikid82 6392ef318b chore: comprehensive update to ignore files
Updated .gitignore, .dockerignore, and .codecov.yml to properly exclude:
- Python artifacts (__pycache__, .venv, coverage)
- Node artifacts (node_modules, dist, coverage, .vite)
- Go artifacts (*.out, coverage files)
- Database files (*.db, *.sqlite)
- IDE files (.vscode, .idea)
- Build artifacts and temporary files
- Documentation and scripts from Docker context
- Test files from codecov analysis
2025-11-19 09:50:08 -05:00
Wikid82 3401757a10 feat: add Dockerfile validation to pre-commit hooks
Added custom Dockerfile validation script that detects mismatches between
base image OS (Debian vs Alpine) and package managers (apt vs apk).

This will catch errors like:
- Using golang:latest (Debian) with apk commands
- Using alpine images with apt commands

The hook runs automatically on commit and would have caught the recent
golang:latest + apk mismatch that caused Docker build failures.
2025-11-19 09:50:08 -05:00
Wikid82 08db82d92a fix: use golang:alpine base image for backend builder
The backend-builder stage was using golang:latest (Debian) but trying to
run Alpine's apk package manager, causing build failures with 'apk: not
found'. Changed to golang:alpine for consistency with the RUN commands.

Fixes Docker build workflow failures in GitHub Actions.
2025-11-19 09:50:07 -05:00
Wikid82 be2b99f7e4 feat: update docker-compose to use GHCR images and fix test failures
- Update docker-compose.yml to use ghcr.io/wikid82/caddyproxymanagerplus:latest
- Update docker-compose.dev.yml to use ghcr.io/wikid82/caddyproxymanagerplus:dev
- Fix backend test database isolation (remove shared cache mode)
- Add testConnection and enabledServers to useRemoteServers hook
- Fix frontend test assertions to wait for async state updates
- Wrap mutation assertions in waitFor for proper async handling

Backend tests:  ALL PASSING (22 tests)
Frontend tests: ⚠️ 45/49 passing (4 useImport tests need mock refinement)
2025-11-19 09:50:07 -05:00
Wikid82 e6f8b15e05 fix: remove duplicate type declarations and update package-lock
- Remove duplicate CaddyConfig in proxy_host.go (exists in caddy_config.go)
- Remove duplicate HealthHandler in proxy_host_handler.go
- Fix version variable names in health_handler.go (SemVer→Version, BuildDate→BuildTime)
- Update frontend package-lock.json to sync with package.json dependencies
- Backend now compiles successfully (1 test fails but will be fixed later)
2025-11-19 09:50:06 -05:00
Wikid82 a87b7cebb8 fix: replace Python tests with Go and React tests in CI
This is a Go + React project, not Python. Updated CI workflow to:
- Run Go tests (backend)
- Run frontend tests with npm (React)
- Remove Python test coverage requirement
2025-11-19 09:49:56 -05:00
Wikid82 9bd1604386 fix: exclude generated frontend files from pre-commit hooks
Generated files (coverage/, dist/, .vite/, *.tsbuildinfo) should not be
checked by trailing-whitespace or end-of-file-fixer hooks.
2025-11-19 09:48:37 -05:00
Wikid82 76f937d947 fix: add missing frontend dependencies and remove trailing whitespace
- Add @tanstack/react-query, axios, and clsx to frontend dependencies
- Remove trailing whitespace from multiple files to pass pre-commit hooks
- These were required but missing from package.json
2025-11-19 09:48:37 -05:00
Wikid82 b570e4c025 docs: simplify GitHub setup guide - no manual token needed
GitHub Actions automatically provides GITHUB_TOKEN with correct permissions
for GHCR, so users don't need to create PATs anymore.
2025-11-19 09:48:36 -05:00
Wikid82 0a7442d703 fix: update ruff version requirement and use GITHUB_TOKEN for GHCR auth
- Change ruff>=0.15 to ruff>=0.6.0 (0.15 doesn't exist yet)
- Replace CPMP_GHCR_TOKEN with GITHUB_TOKEN for GHCR authentication
- GITHUB_TOKEN is automatically provided by GitHub Actions with correct permissions
2025-11-19 09:47:55 -05:00
Wikid82 cabf57df86 fix: Update CI configuration to prevent failure on Codecov errors and clean up condition syntax in branch propagation workflow 2025-11-19 09:45:43 -05:00
renovate[bot] 54fcc18ae1 chore(deps): update dependency black to >=24.10.0 2025-11-19 09:45:43 -05:00
renovate[bot] 5a600ac846 chore(deps): update dependency httpx to >=0.28.1 2025-11-19 09:45:42 -05:00
Jeremy 8bb7cbbf89 Merge pull request #84 from Wikid82/renovate/bandit-1.x
chore(deps): update dependency bandit to >=1.9.1
2025-11-19 09:13:04 -05:00
Jeremy 4280a7ddf5 Merge pull request #83 from Wikid82/renovate/alembic-1.x
chore(deps): update dependency alembic to >=1.17.2
2025-11-19 09:12:48 -05:00
Jeremy 9323ef2d15 Merge pull request #82 from Wikid82/renovate/actions-add-to-project-0.x
chore(deps): update actions/add-to-project action to v0.6.1
2025-11-19 09:12:33 -05:00
renovate[bot] b23ad2a8ab chore(deps): update actions/add-to-project action to v0.6.1 2025-11-19 14:12:23 +00:00
Jeremy 49464e7538 Merge pull request #81 from Wikid82/renovate/pin-dependencies
chore(deps): pin dependencies
2025-11-19 09:11:48 -05:00
renovate[bot] a030545bfb chore(deps): update dependency alembic to >=1.17.2 2025-11-19 14:04:43 +00:00
renovate[bot] bac3e8126c chore(deps): pin dependencies 2025-11-19 14:02:16 +00:00
renovate[bot] 042b2c6ca1 chore(deps): update dependency bandit to >=1.9.1 2025-11-19 04:15:29 +00:00
Jeremy 42dc367876 Merge pull request #54 from Wikid82/Database-Schema-&-Models
feat: Database schema, models, services, and Caddyfile import workflow
2025-11-18 13:24:50 -05:00
Wikid82 9431625d0b Merge branch 'development' into Database-Schema-&-Models
Resolved conflicts by keeping Database-Schema-&-Models version which contains:
- Complete Phase 7 documentation polish (ELI5 style)
- GitHub Actions CI/CD workflows (Docker + Pages)
- GHCR migration (replacing Docker Hub)
- All backend and frontend improvements
2025-11-18 13:20:20 -05:00
Jeremy 764e3b2dde Merge pull request #53 from Wikid82/Caddy-Integration-&-Configuration-Management
feat: Single-container deployment & automated semantic versioning
2025-11-18 13:12:21 -05:00
Wikid82 e58fcb714d docs: comprehensive documentation polish & CI/CD automation
Major Updates:
- Rewrote all docs in beginner-friendly 'ELI5' language
- Created docs index with user journey navigation
- Added complete getting-started guide for novice users
- Set up GitHub Container Registry (GHCR) automation
- Configured GitHub Pages deployment for documentation

Documentation:
- docs/index.md - Central navigation hub
- docs/getting-started.md - Step-by-step beginner guide
- docs/github-setup.md - CI/CD setup instructions
- README.md - Complete rewrite in accessible language
- CONTRIBUTING.md - Contributor guidelines
- Multiple comprehensive API and schema docs

CI/CD Workflows:
- .github/workflows/docker-build.yml - Multi-platform builds to GHCR
- .github/workflows/docs.yml - Automated docs deployment to Pages
- Supports main (latest), development (dev), and version tags
- Automated testing of built images
- Beautiful documentation site with dark theme

Benefits:
- Zero barrier to entry for new users
- Automated Docker builds (AMD64 + ARM64)
- Professional documentation site
- No Docker Hub account needed (uses GHCR)
- Complete CI/CD pipeline

All 7 implementation phases complete - project is production ready!
2025-11-18 13:11:11 -05:00
Wikid82 b9dcc6c347 feat(backend): implement database schema, models, and import workflow
- Add ProxyHost, CaddyConfig, RemoteServer, SSL, AccessList, User, Setting, ImportSession models
- Implement ProxyHostService and RemoteServerService with domain/name uniqueness validation
- Add Caddyfile import handler with conflict detection and user review workflow
- Create ProxyHostHandler and RemoteServerHandler with full CRUD operations
- Wire up Gin/GORM/SQLite dependencies and create missing internal packages
- Add database connection layer, server routing, and version info
- Update routes to register all new handlers and auto-migrate models
- Configure import environment variables and mount points

Addresses Issue #5 (data persistence) and Issue #43 (remote servers)
Backend now compiles cleanly with go build
2025-11-18 11:09:10 -05:00
Wikid82 a04fad2b73 feat: update issue templates and workflows; add python compile check and sourcery integration 2025-11-18 10:57:03 -05:00
Wikid82 4026ce7138 chore: remove committed frontend/node_modules and add to .gitignore 2025-11-17 22:19:48 -05:00
Wikid82 4602cbd100 chore: defer Sourcery auth; continue work 2025-11-17 22:08:59 -05:00
Wikid82 ae9014092b feat: add go test coverage enforcement script and update pre-commit configuration 2025-11-17 19:42:49 -05:00
Wikid82 5dd5036661 feat: single-container deployment & automated semantic versioning; add release workflow, version injection, health endpoint metadata, documentation 2025-11-17 19:29:25 -05:00
Wikid82 b17e7d3d5f feat: implement Caddy integration with Docker-first approach (Issue #4)
- Add Caddy client package (client.go) with Load/GetConfig/Ping methods
- Implement config generator (config.go) transforming ProxyHost → Caddy JSON
- Add pre-flight validator (validator.go) catching config errors before reload
- Create manager (manager.go) with rollback capability using config snapshots
- Add CaddyConfig model for audit trail of configuration changes
- Update Config to include Caddy admin API and config dir settings
- Create comprehensive unit tests with 100% coverage for caddy package

Docker Infrastructure:
- Add docker-compose.yml with Caddy sidecar container
- Add docker-compose.dev.yml for development overrides
- Create .github/workflows/docker-publish.yml for GHCR publishing
- Update CI to build Docker images and run integration tests
- Add DOCKER.md with comprehensive deployment guide
- Update Makefile with docker-compose commands
- Update README with Docker-first deployment instructions

Configuration:
- Add CPM_CADDY_ADMIN_API and CPM_CADDY_CONFIG_DIR env vars
- Update .env.example with new Caddy settings
- Update AutoMigrate to include CaddyConfig model

All acceptance criteria met:
 Can programmatically generate valid Caddy JSON configs
 Can reload Caddy configuration via admin API
 Invalid configs caught by validator before reload
 Automatic rollback on failure via snapshot system
2025-11-17 19:03:59 -05:00
Jeremy 72b4c69cd1 Merge pull request #52 from Wikid82/Project-Architecture-&-Tech-Stack-Selection
Project: Update CI, Dockerfile, Makefile, pre-commit hooks, and docs
2025-11-17 18:35:52 -05:00
Jeremy 01ef53256a Update frontend/src/pages/ProxyHosts.tsx
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-11-17 18:34:52 -05:00
Wikid82 65944e3455 ci: fix go module cache key to use backend/go.sum 2025-11-17 18:33:00 -05:00
Wikid82 89e1850d73 fix: update auto-add-to-project workflow to use secret for project URL and add skip condition 2025-11-17 18:28:53 -05:00
Wikid82 6b0dfa7085 ci: add Go/Node CI, Docker multi-stage, Makefile, and pre-commit hooks; update README 2025-11-17 18:16:03 -05:00
Wikid82 4f3b7d8f99 feat: Initial project setup with CI/CD, Docker, and issue creation script
- Added Codecov configuration to enforce 75% coverage.
- Created .dockerignore to exclude unnecessary files from Docker context.
- Implemented GitHub Actions CI workflow for linting, testing, and coverage reporting.
- Added a workflow to propagate changes between main and development branches.
- Configured pre-commit hooks for code quality checks.
- Developed a multi-stage Dockerfile for a Python web backend.
- Added MIT License to the project.
- Created README.md with project overview and setup instructions.
- Implemented a script to create GitHub issues from project planning.
- Defined development and runtime requirements in requirements.txt and requirements.dev.txt.
2025-11-17 15:52:40 -05:00
Wikid82 5b946ac880 Add initial workspace configuration file 2025-11-17 15:02:35 -05:00
Wikid82 09fa6d0ad1 Add GitHub Project Board Setup & Automation Guide 2025-11-17 15:02:28 -05:00
Wikid82 d638adf6bb Add project planning document outlining vision, milestones, and issue structure 2025-11-17 15:02:22 -05:00
Wikid82 d82defc555 Add GitHub workflows for auto-adding issues/PRs, auto-labeling, and creating project labels 2025-11-17 15:01:53 -05:00
Wikid82 dc19113e68 Add new issue templates for Alpha, Beta Monitoring, and Beta Security features; remove custom template 2025-11-17 15:01:43 -05:00
Jeremy d7e92d41e2 Update GitHub Sponsors username in FUNDING.yml 2025-11-17 12:54:29 -05:00
Jeremy 160166ce95 Update issue templates 2025-11-17 12:53:16 -05:00
397 changed files with 62155 additions and 10969 deletions
+40
View File
@@ -0,0 +1,40 @@
# 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
@@ -0,0 +1,113 @@
# 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
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,93 @@
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
@@ -0,0 +1,118 @@
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
@@ -0,0 +1,116 @@
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
@@ -0,0 +1,38 @@
---
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
@@ -0,0 +1,20 @@
---
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.
@@ -0,0 +1,97 @@
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
@@ -0,0 +1,51 @@
# 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
@@ -0,0 +1,26 @@
name-template: 'v$NEXT_PATCH_VERSION'
tag-template: 'v$NEXT_PATCH_VERSION'
categories:
- title: '🚀 Features'
labels:
- 'feature'
- 'feat'
- title: '🐛 Fixes'
labels:
- 'bug'
- 'fix'
- title: '🧰 Maintenance'
labels:
- 'chore'
- title: '🧪 Tests'
labels:
- 'test'
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
template: |
## What's Changed
$CHANGES
----
Full Changelog: https://github.com/${{ github.repository }}/compare/$FROM_TAG...$TO_TAG
+72
View File
@@ -0,0 +1,72 @@
{
"$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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,17 @@
name: Auto Changelog (Release Drafter)
on:
push:
branches: [ main ]
release:
types: [published]
jobs:
update-draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Draft Release
uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+74
View File
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,53 @@
name: Auto Versioning and Release
on:
push:
branches: [ main ]
permissions:
contents: write
pull-requests: write
jobs:
version:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate semantic version (fallback script)
id: semver
run: |
# Ensure git tags are fetched
git fetch --tags --quiet || true
# Get latest tag or default to v0.0.0
TAG=$(git describe --abbrev=0 --tags 2>/dev/null || echo "v0.0.0")
echo "Detected latest tag: $TAG"
# Set outputs for downstream steps
echo "version=$TAG" >> $GITHUB_OUTPUT
echo "release_notes=Fallback: using latest tag only" >> $GITHUB_OUTPUT
echo "changed=false" >> $GITHUB_OUTPUT
- name: Show version
run: |
echo "Next version: ${{ steps.semver.outputs.version }}"
- name: Create annotated tag and push
if: ${{ steps.semver.outputs.changed }}
run: |
git tag -a v${{ steps.semver.outputs.version }} -m "Release v${{ steps.semver.outputs.version }}"
git push origin --tags
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub Release (tag-only, no workspace changes)
if: ${{ steps.semver.outputs.changed }}
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.semver.outputs.version }}
name: Release ${{ steps.semver.outputs.version }}
body: ${{ steps.semver.outputs.release_notes }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+52
View File
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,62 @@
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
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,297 @@
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
@@ -0,0 +1,353 @@
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
@@ -0,0 +1,107 @@
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
@@ -0,0 +1,120 @@
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
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,121 @@
# 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
@@ -0,0 +1,125 @@
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
@@ -0,0 +1,103 @@
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
@@ -0,0 +1,4 @@
version: 1
exclude:
- frontend/dist/**
- frontend/node_modules/**
+1
View File
@@ -0,0 +1 @@
0.3.0
+22
View File
@@ -0,0 +1,22 @@
{
"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
@@ -0,0 +1,5 @@
{
"githubPullRequests.ignoredPullRequestBranches": [
"main"
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"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
@@ -0,0 +1,137 @@
{
"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
@@ -0,0 +1,387 @@
# 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
@@ -0,0 +1,10 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"codeQL.createQuery.qlPackLocation": "/projects/Charon"
}
}
+203
View File
@@ -0,0 +1,203 @@
# 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
@@ -0,0 +1,202 @@
# 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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,160 @@
.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
@@ -0,0 +1,122 @@
<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
@@ -0,0 +1,148 @@
# 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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,19 @@
# 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
@@ -0,0 +1,122 @@
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
@@ -0,0 +1,248 @@
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
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,229 @@
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
@@ -0,0 +1,447 @@
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
@@ -0,0 +1,162 @@
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)
}
@@ -0,0 +1,415 @@
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")
}
}
@@ -0,0 +1,111 @@
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"})
}
@@ -0,0 +1,295 @@
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)
}
@@ -0,0 +1,79 @@
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."})
}
@@ -0,0 +1,330 @@
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)
}
@@ -0,0 +1,137 @@
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"})
}
@@ -0,0 +1,389 @@
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)
}
@@ -0,0 +1,52 @@
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)
}
@@ -0,0 +1,171 @@
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)
}
@@ -0,0 +1,92 @@
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"})
}
@@ -0,0 +1,160 @@
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)
}
@@ -0,0 +1,368 @@
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)
}
@@ -0,0 +1,38 @@
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(),
})
}
@@ -0,0 +1,29 @@
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"])
}
@@ -0,0 +1,709 @@
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
}
@@ -0,0 +1,30 @@
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)
}
}
}
@@ -0,0 +1,888 @@
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")
})
}
@@ -0,0 +1,106 @@
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())
}
@@ -0,0 +1,161 @@
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")
}
@@ -0,0 +1,43 @@
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"})
}
@@ -0,0 +1,148 @@
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)
}
@@ -0,0 +1,143 @@
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})
}
@@ -0,0 +1,231 @@
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)
}
@@ -0,0 +1,97 @@
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})
}
@@ -0,0 +1,52 @@
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)
}
@@ -0,0 +1,324 @@
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,
})
}
@@ -0,0 +1,519 @@
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)
}
@@ -0,0 +1,237 @@
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)
}
@@ -0,0 +1,129 @@
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
@@ -0,0 +1,20 @@
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
}
@@ -0,0 +1,24 @@
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)
}
}
}
@@ -0,0 +1,65 @@
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",
},
})
}
@@ -0,0 +1,82 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
)
func setupTestDB(t *testing.T) *gorm.DB {
// lightweight in-memory DB unique per test run
dsn := fmt.Sprintf("file:security_handler_test_%d?mode=memory&cache=shared", time.Now().UnixNano())
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open DB: %v", err)
}
if err := db.AutoMigrate(&models.Setting{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
return db
}
func TestSecurityHandler_GetStatus_Clean(t *testing.T) {
gin.SetMode(gin.TestMode)
// Basic disabled scenario
cfg := config.SecurityConfig{
CrowdSecMode: "disabled",
WAFMode: "disabled",
RateLimitMode: "disabled",
ACLMode: "disabled",
}
handler := NewSecurityHandler(cfg, nil)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.NotNil(t, response["cerberus"])
}
func TestSecurityHandler_Cerberus_DBOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
// set DB to enable cerberus
if err := db.Create(&models.Setting{Key: "security.cerberus.enabled", Value: "true"}).Error; err != nil {
t.Fatalf("failed to insert setting: %v", err)
}
cfg := config.SecurityConfig{CerberusEnabled: false}
handler := NewSecurityHandler(cfg, db)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
cerb := response["cerberus"].(map[string]interface{})
assert.Equal(t, true, cerb["enabled"].(bool))
}
@@ -0,0 +1,888 @@
//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)
})
}
}
@@ -0,0 +1,111 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/Wikid82/charon/backend/internal/config"
)
func TestSecurityHandler_GetStatus_Fixed(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
cfg config.SecurityConfig
expectedStatus int
expectedBody map[string]interface{}
}{
{
name: "All Disabled",
cfg: config.SecurityConfig{
CrowdSecMode: "disabled",
WAFMode: "disabled",
RateLimitMode: "disabled",
ACLMode: "disabled",
},
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"cerberus": map[string]interface{}{"enabled": false},
"crowdsec": map[string]interface{}{
"mode": "disabled",
"api_url": "",
"enabled": false,
},
"waf": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
"rate_limit": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
"acl": map[string]interface{}{
"mode": "disabled",
"enabled": false,
},
},
},
{
name: "All Enabled",
cfg: config.SecurityConfig{
CrowdSecMode: "local",
WAFMode: "enabled",
RateLimitMode: "enabled",
ACLMode: "enabled",
},
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"cerberus": map[string]interface{}{"enabled": true},
"crowdsec": map[string]interface{}{
"mode": "local",
"api_url": "",
"enabled": true,
},
"waf": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
"rate_limit": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
"acl": map[string]interface{}{
"mode": "enabled",
"enabled": true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handler := NewSecurityHandler(tt.cfg, nil)
router := gin.New()
router.GET("/security/status", handler.GetStatus)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/status", nil)
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
expectedJSON, _ := json.Marshal(tt.expectedBody)
var expectedNormalized map[string]interface{}
if err := json.Unmarshal(expectedJSON, &expectedNormalized); err != nil {
t.Fatalf("failed to unmarshal expected JSON: %v", err)
}
assert.Equal(t, expectedNormalized, response)
})
}
}
@@ -0,0 +1,71 @@
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)
}
@@ -0,0 +1,121 @@
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)
}
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,2 @@
#!/bin/sh
echo '{"apps":{}}'
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
if [ "$1" = "version" ]; then
echo "v2.0.0"
exit 0
fi
exit 1
+15
View File
@@ -0,0 +1,15 @@
#!/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
@@ -0,0 +1,25 @@
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