feat: implement modular security services with CrowdSec and WAF integration

This commit is contained in:
Wikid82
2025-11-26 18:35:14 +00:00
parent 06d0aca8a4
commit c8a452f1a0
14 changed files with 526 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {
<Route path="remote-servers" element={<RemoteServers />} />
<Route path="domains" element={<Domains />} />
<Route path="certificates" element={<Certificates />} />
<Route path="security" element={<Security />} />
<Route path="uptime" element={<Uptime />} />
<Route path="notifications" element={<Notifications />} />
<Route path="import" element={<ImportCaddy />} />

View File

@@ -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<SecurityStatus> => {
const response = await client.get<SecurityStatus>('/security/status')
return response.data
}

View File

@@ -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'}
`}>
<div className={`h-16 flex items-center justify-center border-b border-gray-200 dark:border-gray-800`}>
<div className={`h-20 flex items-center justify-center border-b border-gray-200 dark:border-gray-800`}>
{isCollapsed ? (
<img src="/logo.png" alt="CPM+" className="h-10 w-10" />
<img src="/logo.png" alt="CPM+" className="h-12 w-10" />
) : (
<img src="/banner.png" alt="CPM+" className="h-12 w-auto" />
<img src="/banner.png" alt="CPM+" className="h-16 w-auto" />
)}
</div>
<div className="flex flex-col flex-1 px-4 mt-16 lg:mt-0">
<div className="flex flex-col flex-1 px-4 mt-16 lg:mt-6">
<nav className="flex-1 space-y-1">
{navigation.map((item) => {
if (item.children) {
@@ -246,7 +247,7 @@ export default function Layout({ children }: LayoutProps) {
{/* Main Content */}
<main className={`flex-1 min-w-0 overflow-auto pt-16 lg:pt-0 flex flex-col transition-all duration-200 ${isCollapsed ? 'lg:ml-20' : 'lg:ml-64'}`}>
{/* Desktop Header */}
<header className="hidden lg:flex items-center justify-between px-8 py-4 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 relative">
<header className="hidden lg:flex items-center justify-between px-8 h-20 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 relative">
<div className="w-1/3 flex items-center gap-4">
<button
onClick={() => setIsCollapsed(!isCollapsed)}

View File

@@ -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 <div className="p-8 text-center">Loading security status...</div>
}
if (!status) {
return <div className="p-8 text-center text-red-500">Failed to load security status</div>
}
const allDisabled = !status.crowdsec.enabled && !status.waf.enabled && !status.rate_limit.enabled && !status.acl.enabled
if (allDisabled) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center space-y-6">
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full">
<Shield className="w-16 h-16 text-gray-400" />
</div>
<div className="max-w-md space-y-2">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Security Services Not Enabled</h2>
<p className="text-gray-500 dark:text-gray-400">
CaddyProxyManager+ supports advanced security features like CrowdSec, WAF, ACLs, and Rate Limiting.
These are optional and can be enabled via environment variables.
</p>
</div>
<Button
variant="primary"
onClick={() => window.open('https://wikid82.github.io/CaddyProxyManagerPlus/security', '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
View Implementation Guide
</Button>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<ShieldCheck className="w-8 h-8 text-green-500" />
Security Dashboard
</h1>
<Button
variant="secondary"
onClick={() => window.open('https://wikid82.github.io/CaddyProxyManagerPlus/security', '_blank')}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
Documentation
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* CrowdSec */}
<Card className={status.crowdsec.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">CrowdSec</h3>
<ShieldAlert className={`w-4 h-4 ${status.crowdsec.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.crowdsec.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{status.crowdsec.enabled
? `Mode: ${status.crowdsec.mode}`
: 'Intrusion Prevention System'}
</p>
{status.crowdsec.enabled && (
<div className="mt-4">
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => window.open(status.crowdsec.mode === 'external' ? status.crowdsec.api_url : 'http://localhost:8080', '_blank')}
>
Open Console
</Button>
</div>
)}
</div>
</Card>
{/* WAF */}
<Card className={status.waf.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">WAF (Coraza)</h3>
<Shield className={`w-4 h-4 ${status.waf.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.waf.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
OWASP Core Rule Set
</p>
</div>
</Card>
{/* ACL */}
<Card className={status.acl.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">Access Control</h3>
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.acl.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
IP-based Allow/Deny Lists
</p>
{status.acl.enabled && (
<div className="mt-4">
<Button variant="secondary" size="sm" className="w-full">
Manage Lists
</Button>
</div>
)}
</div>
</Card>
{/* Rate Limiting */}
<Card className={status.rate_limit.enabled ? 'border-green-200 dark:border-green-900' : ''}>
<div className="flex flex-row items-center justify-between pb-2">
<h3 className="text-sm font-medium text-white">Rate Limiting</h3>
<Activity className={`w-4 h-4 ${status.rate_limit.enabled ? 'text-green-500' : 'text-gray-400'}`} />
</div>
<div>
<div className="text-2xl font-bold mb-1 text-white">
{status.rate_limit.enabled ? 'Active' : 'Disabled'}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
DDoS Protection
</p>
{status.rate_limit.enabled && (
<div className="mt-4">
<Button variant="secondary" size="sm" className="w-full">
Configure Limits
</Button>
</div>
)}
</div>
</Card>
</div>
</div>
)
}