diff --git a/Dockerfile b/Dockerfile index a7f506ae..be48086e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,6 +93,8 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v2.9.1 \ --with github.com/greenpau/caddy-security \ + --with github.com/corazawaf/coraza-caddy/v2 \ + --with github.com/hslatman/caddy-crowdsec-bouncer \ --replace github.com/quic-go/quic-go=github.com/quic-go/quic-go@v0.49.1 \ --replace golang.org/x/crypto=golang.org/x/crypto@v0.35.0 \ --output /usr/bin/caddy diff --git a/SECURITY_IMPLEMENTATION_PLAN.md b/SECURITY_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..6450d685 --- /dev/null +++ b/SECURITY_IMPLEMENTATION_PLAN.md @@ -0,0 +1,113 @@ +# Security Services Implementation Plan + +## Overview +This document outlines the plan to implement a modular Security Dashboard in CaddyProxyManager+ (CPM+). The goal is to provide optional, high-value security integrations (CrowdSec, WAF, ACLs, Rate Limiting) while keeping the core Docker image lightweight. + +## Core Philosophy +1. **Optionality**: All security services are disabled by default. +2. **Environment Driven**: Activation is controlled via `CPM_SECURITY_*` environment variables. +3. **Minimal Footprint**: + * Lightweight Caddy modules (WAF, Bouncers) are compiled into the binary (negligible size impact). + * Heavy standalone agents (e.g., CrowdSec Agent) are only installed at runtime if explicitly enabled in "Local" mode. +4. **Unified Dashboard**: A single pane of glass in the UI to view status and configuration. + +--- + +## 1. Environment Variables +We will introduce a new set of environment variables to control these services. + +| Variable | Values | Description | +| :--- | :--- | :--- | +| `CPM_SECURITY_CROWDSEC_MODE` | `disabled` (default), `local`, `external` | `local` installs agent inside container; `external` uses remote agent. | +| `CPM_SECURITY_CROWDSEC_API_URL` | URL (e.g., `http://crowdsec:8080`) | Required if mode is `external`. | +| `CPM_SECURITY_CROWDSEC_API_KEY` | String | Required if mode is `external`. | +| `CPM_SECURITY_WAF_MODE` | `disabled` (default), `enabled` | Enables Coraza WAF with OWASP Core Rule Set (CRS). | +| `CPM_SECURITY_RATELIMIT_ENABLED` | `true`, `false` (default) | Enables global rate limiting controls. | +| `CPM_SECURITY_ACL_ENABLED` | `true`, `false` (default) | Enables IP-based Access Control Lists. | + +--- + +## 2. Backend Implementation + +### A. Dockerfile Updates +We need to compile the necessary Caddy modules into our binary. This adds minimal size overhead but enables the features natively. +* **Action**: Update `Dockerfile` `caddy-builder` stage to include: + * `github.com/corazawaf/coraza-caddy/v2` (WAF) + * `github.com/hslatman/caddy-crowdsec-bouncer` (CrowdSec Bouncer) + +### B. Configuration Management (`internal/config`) +* **Action**: Update `Config` struct to parse `CPM_SECURITY_*` variables. +* **Action**: Create `SecurityConfig` struct to hold these values. + +### C. Runtime Installation (`docker-entrypoint.sh`) +To satisfy the "install locally" requirement for CrowdSec without bloating the image: +* **Action**: Modify `docker-entrypoint.sh` to check `CPM_SECURITY_CROWDSEC_MODE`. +* **Logic**: If `local`, execute `apk add --no-cache crowdsec` (and dependencies) before starting the app. This keeps the base image small for users who don't use it. + +### D. API Endpoints (`internal/api`) +* **New Endpoint**: `GET /api/v1/security/status` + * Returns the enabled/disabled state of each service. + * Returns basic metrics if available (e.g., "WAF: Active", "CrowdSec: Connected"). + +--- + +## 3. Frontend Implementation + +### A. Navigation +* **Action**: Add "Security" item to the Sidebar in `Layout.tsx`. + +### B. Security Dashboard (`src/pages/Security.tsx`) +* **Layout**: Grid of cards representing each service. +* **Empty State**: If all services are disabled, show a clean "Security Not Enabled" state with a link to the GitHub Pages documentation on how to enable them. + +### C. Service Cards +1. **CrowdSec Card**: + * **Status**: Active (Local/External) / Disabled. + * **Content**: If Local, show basic stats (last push, alerts). If External, show connection status. + * **Action**: Link to CrowdSec Console or Dashboard. +2. **WAF Card**: + * **Status**: Active / Disabled. + * **Content**: "OWASP CRS Loaded". +3. **Access Control Lists (ACL)**: + * **Status**: Active / Disabled. + * **Action**: "Manage Blocklists" (opens modal/page to edit IP lists). +4. **Rate Limiting**: + * **Status**: Active / Disabled. + * **Action**: "Configure Limits" (opens modal to set global requests/second). + +--- + +## 4. Service-Specific Logic + +### CrowdSec +* **Local**: + * Installs CrowdSec agent via `apk`. + * Generates `acquis.yaml` to read Caddy logs. + * Configures Caddy bouncer to talk to `localhost:8080`. +* **External**: + * Configures Caddy bouncer to talk to `CPM_SECURITY_CROWDSEC_API_URL`. + +### WAF (Coraza) +* **Implementation**: + * When enabled, inject `coraza_waf` directive into the global Caddyfile or per-host. + * Use default OWASP Core Rule Set (CRS). + +### IP ACLs +* **Implementation**: + * Create a snippet `(ip_filter)` in Caddyfile. + * Use `@matcher` with `remote_ip` to block/allow IPs. + * UI allows adding CIDR ranges to this list. + +### Rate Limiting +* **Implementation**: + * Use `rate_limit` directive. + * Allow user to define "zones" (e.g., API, Static) in the UI. + +--- + +## 5. Documentation +* **New Doc**: `docs/security.md` +* **Content**: + * Explanation of each service. + * How to configure Env Vars. + * Trade-offs of "Local" CrowdSec (startup time vs convenience). diff --git a/backend/internal/api/handlers/security_handler.go b/backend/internal/api/handlers/security_handler.go new file mode 100644 index 00000000..c64de894 --- /dev/null +++ b/backend/internal/api/handlers/security_handler.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config" +) + +// SecurityHandler handles security-related API requests. +type SecurityHandler struct { + cfg config.SecurityConfig +} + +// NewSecurityHandler creates a new SecurityHandler. +func NewSecurityHandler(cfg config.SecurityConfig) *SecurityHandler { + return &SecurityHandler{ + cfg: cfg, + } +} + +// GetStatus returns the current status of all security services. +func (h *SecurityHandler) GetStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "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{ + "enabled": h.cfg.RateLimitEnabled, + }, + "acl": gin.H{ + "enabled": h.cfg.ACLEnabled, + }, + }) +} diff --git a/backend/internal/api/handlers/security_handler_test.go b/backend/internal/api/handlers/security_handler_test.go new file mode 100644 index 00000000..a3d47153 --- /dev/null +++ b/backend/internal/api/handlers/security_handler_test.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/Wikid82/CaddyProxyManagerPlus/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", + RateLimitEnabled: false, + ACLEnabled: false, + }, + 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{}{ + "enabled": false, + }, + "acl": map[string]interface{}{ + "enabled": false, + }, + }, + }, + { + name: "All Enabled", + cfg: config.SecurityConfig{ + CrowdSecMode: "local", + WAFMode: "enabled", + RateLimitEnabled: true, + ACLEnabled: true, + }, + 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{}{ + "enabled": true, + }, + "acl": map[string]interface{}{ + "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) + }) + } +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 890b9c9b..0a661765 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -170,6 +170,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { go uptimeService.CheckAll() c.JSON(200, gin.H{"message": "Uptime check started"}) }) + + // Security Status + securityHandler := handlers.NewSecurityHandler(cfg.Security) + protected.GET("/security/status", securityHandler.GetStatus) } // Caddy Manager diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3d4c5d31..54e083c2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -19,6 +19,17 @@ type Config struct { ImportDir string JWTSecret string ACMEStaging bool + Security SecurityConfig +} + +// SecurityConfig holds configuration for optional security services. +type SecurityConfig struct { + CrowdSecMode string + CrowdSecAPIURL string + CrowdSecAPIKey string + WAFMode string + RateLimitEnabled bool + ACLEnabled bool } // Load reads env vars and falls back to defaults so the server can boot with zero configuration. @@ -35,6 +46,14 @@ func Load() (Config, error) { ImportDir: getEnv("CPM_IMPORT_DIR", filepath.Join("data", "imports")), JWTSecret: getEnv("CPM_JWT_SECRET", "change-me-in-production"), ACMEStaging: getEnv("CPM_ACME_STAGING", "") == "true", + Security: SecurityConfig{ + CrowdSecMode: getEnv("CPM_SECURITY_CROWDSEC_MODE", "disabled"), + CrowdSecAPIURL: getEnv("CPM_SECURITY_CROWDSEC_API_URL", ""), + CrowdSecAPIKey: getEnv("CPM_SECURITY_CROWDSEC_API_KEY", ""), + WAFMode: getEnv("CPM_SECURITY_WAF_MODE", "disabled"), + RateLimitEnabled: getEnv("CPM_SECURITY_RATELIMIT_ENABLED", "false") == "true", + ACLEnabled: getEnv("CPM_SECURITY_ACL_ENABLED", "false") == "true", + }, } if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b19fe851..0f148cfd 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,6 +19,13 @@ services: - CPM_FRONTEND_DIR=/app/frontend/dist - CPM_CADDY_ADMIN_API=http://localhost:2019 - CPM_CADDY_CONFIG_DIR=/app/data/caddy + # Security Services (Optional) + #- CPM_SECURITY_CROWDSEC_MODE=disabled + #- CPM_SECURITY_CROWDSEC_API_URL= + #- CPM_SECURITY_CROWDSEC_API_KEY= + #- CPM_SECURITY_WAF_MODE=disabled + #- CPM_SECURITY_RATELIMIT_ENABLED=false + #- CPM_SECURITY_ACL_ENABLED=false volumes: - /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery # Mount your existing Caddyfile for automatic import (optional) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0a6dd0f7..8e0a2e9c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -24,6 +24,13 @@ services: - CPM_IMPORT_CADDYFILE=/import/Caddyfile - CPM_IMPORT_DIR=/app/data/imports - CPM_ACME_STAGING=false + # Security Services (Optional) + - CPM_SECURITY_CROWDSEC_MODE=disabled + - CPM_SECURITY_CROWDSEC_API_URL= + - CPM_SECURITY_CROWDSEC_API_KEY= + - CPM_SECURITY_WAF_MODE=disabled + - CPM_SECURITY_RATELIMIT_ENABLED=false + - CPM_SECURITY_ACL_ENABLED=false extra_hosts: - "host.docker.internal:host-gateway" cap_add: diff --git a/docker-compose.yml b/docker-compose.yml index cadca008..2391c521 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,13 @@ services: - CPM_CADDY_BINARY=caddy - CPM_IMPORT_CADDYFILE=/import/Caddyfile - CPM_IMPORT_DIR=/app/data/imports + # Security Services (Optional) + #- CPM_SECURITY_CROWDSEC_MODE=disabled # disabled, local, external + #- CPM_SECURITY_CROWDSEC_API_URL= # Required if mode is external + #- CPM_SECURITY_CROWDSEC_API_KEY= # Required if mode is external + #- CPM_SECURITY_WAF_MODE=disabled # disabled, enabled + #- CPM_SECURITY_RATELIMIT_ENABLED=false + #- CPM_SECURITY_ACL_ENABLED=false extra_hosts: - "host.docker.internal:host-gateway" volumes: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 07477d02..885ffe7c 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -6,6 +6,29 @@ set -e echo "Starting CaddyProxyManager+ with integrated Caddy..." +# Optional: Install and start CrowdSec (Local Mode) +CROWDSEC_PID="" +if [ "$CPM_SECURITY_CROWDSEC_MODE" = "local" ]; then + echo "CrowdSec Local Mode enabled. Installing CrowdSec agent..." + # Install crowdsec from community repository if needed + apk add --no-cache crowdsec --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community || \ + apk add --no-cache crowdsec || \ + echo "Failed to install crowdsec. Check repositories." + + if command -v crowdsec >/dev/null; then + echo "Starting CrowdSec agent..." + # Ensure configuration exists or is generated (basic check) + if [ ! -d "/etc/crowdsec" ]; then + echo "Warning: /etc/crowdsec not found. CrowdSec might fail to start." + fi + crowdsec & + CROWDSEC_PID=$! + echo "CrowdSec started (PID: $CROWDSEC_PID)" + else + echo "CrowdSec binary not found after installation attempt." + fi +fi + # Start Caddy in the background with initial empty config echo '{"apps":{}}' > /config/caddy.json # Use JSON config directly; no adapter needed @@ -40,6 +63,11 @@ shutdown() { echo "Shutting down..." kill -TERM "$APP_PID" 2>/dev/null || true kill -TERM "$CADDY_PID" 2>/dev/null || true + if [ -n "$CROWDSEC_PID" ]; then + echo "Stopping CrowdSec..." + kill -TERM "$CROWDSEC_PID" 2>/dev/null || true + wait "$CROWDSEC_PID" 2>/dev/null || true + fi wait "$APP_PID" 2>/dev/null || true wait "$CADDY_PID" 2>/dev/null || true exit 0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 53dc10ec..d2602c0c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ const Backups = lazy(() => import('./pages/Backups')) const Tasks = lazy(() => import('./pages/Tasks')) const Logs = lazy(() => import('./pages/Logs')) const Domains = lazy(() => import('./pages/Domains')) +const Security = lazy(() => import('./pages/Security')) const Uptime = lazy(() => import('./pages/Uptime')) const Notifications = lazy(() => import('./pages/Notifications')) const Login = lazy(() => import('./pages/Login')) @@ -47,6 +48,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts new file mode 100644 index 00000000..737fef14 --- /dev/null +++ b/frontend/src/api/security.ts @@ -0,0 +1,24 @@ +import client from './client' + +export interface SecurityStatus { + crowdsec: { + mode: 'disabled' | 'local' | 'external' + api_url: string + enabled: boolean + } + waf: { + mode: 'disabled' | 'enabled' + enabled: boolean + } + rate_limit: { + enabled: boolean + } + acl: { + enabled: boolean + } +} + +export const getSecurityStatus = async (): Promise => { + const response = await client.get('/security/status') + return response.data +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 14226868..d0add570 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -47,6 +47,7 @@ export default function Layout({ children }: LayoutProps) { { name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' }, { name: 'Domains', path: '/domains', icon: '🌍' }, { name: 'Certificates', path: '/certificates', icon: '🔒' }, + { name: 'Security', path: '/security', icon: '🛡️' }, { name: 'Uptime', path: '/uptime', icon: '📈' }, { name: 'Notifications', path: '/notifications', icon: '🔔' }, { name: 'Import Caddyfile', path: '/import', icon: '📥' }, @@ -91,15 +92,15 @@ export default function Layout({ children }: LayoutProps) { ${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} ${isCollapsed ? 'w-20' : 'w-64'} `}> - + {isCollapsed ? ( - + ) : ( - + )} - + {navigation.map((item) => { if (item.children) { @@ -246,7 +247,7 @@ export default function Layout({ children }: LayoutProps) { {/* Main Content */} {/* Desktop Header */} - + setIsCollapsed(!isCollapsed)} diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx new file mode 100644 index 00000000..cce32db4 --- /dev/null +++ b/frontend/src/pages/Security.tsx @@ -0,0 +1,160 @@ +import { useQuery } from '@tanstack/react-query' +import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink } from 'lucide-react' +import { getSecurityStatus } from '../api/security' +import { Card } from '../components/ui/Card' +import { Button } from '../components/ui/Button' + +export default function Security() { + const { data: status, isLoading } = useQuery({ + queryKey: ['security-status'], + queryFn: getSecurityStatus, + }) + + if (isLoading) { + return Loading security status... + } + + if (!status) { + return Failed to load security status + } + + const allDisabled = !status.crowdsec.enabled && !status.waf.enabled && !status.rate_limit.enabled && !status.acl.enabled + + if (allDisabled) { + return ( + + + + + + Security Services Not Enabled + + CaddyProxyManager+ supports advanced security features like CrowdSec, WAF, ACLs, and Rate Limiting. + These are optional and can be enabled via environment variables. + + + window.open('https://wikid82.github.io/CaddyProxyManagerPlus/security', '_blank')} + className="flex items-center gap-2" + > + + View Implementation Guide + + + ) + } + + return ( + + + + + Security Dashboard + + window.open('https://wikid82.github.io/CaddyProxyManagerPlus/security', '_blank')} + className="flex items-center gap-2" + > + + Documentation + + + + + {/* CrowdSec */} + + + CrowdSec + + + + + {status.crowdsec.enabled ? 'Active' : 'Disabled'} + + + {status.crowdsec.enabled + ? `Mode: ${status.crowdsec.mode}` + : 'Intrusion Prevention System'} + + {status.crowdsec.enabled && ( + + window.open(status.crowdsec.mode === 'external' ? status.crowdsec.api_url : 'http://localhost:8080', '_blank')} + > + Open Console + + + )} + + + + {/* WAF */} + + + WAF (Coraza) + + + + + {status.waf.enabled ? 'Active' : 'Disabled'} + + + OWASP Core Rule Set + + + + + {/* ACL */} + + + Access Control + + + + + {status.acl.enabled ? 'Active' : 'Disabled'} + + + IP-based Allow/Deny Lists + + {status.acl.enabled && ( + + + Manage Lists + + + )} + + + + {/* Rate Limiting */} + + + Rate Limiting + + + + + {status.rate_limit.enabled ? 'Active' : 'Disabled'} + + + DDoS Protection + + {status.rate_limit.enabled && ( + + + Configure Limits + + + )} + + + + + ) +}
+ CaddyProxyManager+ supports advanced security features like CrowdSec, WAF, ACLs, and Rate Limiting. + These are optional and can be enabled via environment variables. +
+ {status.crowdsec.enabled + ? `Mode: ${status.crowdsec.mode}` + : 'Intrusion Prevention System'} +
+ OWASP Core Rule Set +
+ IP-based Allow/Deny Lists +
+ DDoS Protection +