diff --git a/README.md b/README.md index f803485b..75d079c8 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,50 @@ ## 🚀 Quick Start ```bash -# Clone and start -git clone https://github.com/Wikid82/cpmp.git -cd cpmp -docker compose up -d +services: + cpmp: + image: ghcr.io/wikid82/cpmp:latest + container_name: cpmp + 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 (CPM+) + environment: + - CPM_ENV=production + - TZ=UTC # Set timezone (e.g., America/New_York) + - 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_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: + - :/app/data + - :/data + - :/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! 🎉 diff --git a/backend/internal/services/access_list_service.go b/backend/internal/services/access_list_service.go index 7cbb8709..4fd5b621 100644 --- a/backend/internal/services/access_list_service.go +++ b/backend/internal/services/access_list_service.go @@ -36,16 +36,35 @@ var RFC1918PrivateNetworks = []string{ "::1/128", // IPv6 localhost } -// ISO 3166-1 alpha-2 country codes (subset for validation) +// ISO 3166-1 alpha-2 country codes (comprehensive list for validation) var validCountryCodes = map[string]bool{ - "US": true, "CA": true, "GB": true, "DE": true, "FR": true, "IT": true, "ES": true, - "NL": true, "BE": true, "SE": true, "NO": true, "DK": true, "FI": true, "PL": true, - "CZ": true, "AT": true, "CH": true, "AU": true, "NZ": true, "JP": true, "CN": true, - "IN": true, "BR": true, "MX": true, "AR": true, "RU": true, "UA": true, "TR": true, - "IL": true, "SA": true, "AE": true, "EG": true, "ZA": true, "KR": true, "SG": true, - "MY": true, "TH": true, "ID": true, "PH": true, "VN": true, "IE": true, "PT": true, - "GR": true, "HU": true, "RO": true, "BG": true, "HR": true, "SI": true, "SK": true, - "LT": true, "LV": true, "EE": true, "IS": true, "LU": true, "MT": true, "CY": true, + // North America + "US": true, "CA": true, "MX": true, + // Europe + "GB": true, "DE": true, "FR": true, "IT": true, "ES": true, "NL": true, "BE": true, + "SE": true, "NO": true, "DK": true, "FI": true, "PL": true, "CZ": true, "AT": true, + "CH": true, "IE": true, "PT": true, "GR": true, "HU": true, "RO": true, "BG": true, + "HR": true, "SI": true, "SK": true, "LT": true, "LV": true, "EE": true, "IS": true, + "LU": true, "MT": true, "CY": true, "UA": true, "BY": true, + // Asia + "JP": true, "CN": true, "IN": true, "KR": true, "SG": true, "MY": true, "TH": true, + "ID": true, "PH": true, "VN": true, "TW": true, "HK": true, "PK": true, "BD": true, + "KP": true, "IR": true, "IQ": true, "SY": true, "AF": true, "LK": true, "MM": true, + // Middle East + "TR": true, "IL": true, "SA": true, "AE": true, "QA": true, "KW": true, "OM": true, + "BH": true, "JO": true, "LB": true, "YE": true, + // Africa + "EG": true, "ZA": true, "NG": true, "KE": true, "ET": true, "TZ": true, "MA": true, + "DZ": true, "SD": true, "UG": true, "GH": true, + // South America + "BR": true, "AR": true, "CL": true, "CO": true, "PE": true, "VE": true, "EC": true, + "BO": true, "PY": true, "UY": true, + // Caribbean / Central America + "CU": true, "DO": true, "PR": true, "JM": true, "HT": true, "PA": true, "CR": true, + // Oceania + "AU": true, "NZ": true, + // Russia & CIS + "RU": true, "KZ": true, "UZ": true, "AZ": true, "GE": true, "AM": true, } type AccessListService struct { diff --git a/frontend/src/components/AccessListForm.tsx b/frontend/src/components/AccessListForm.tsx index 0b371276..cd55f25c 100644 --- a/frontend/src/components/AccessListForm.tsx +++ b/frontend/src/components/AccessListForm.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Button } from './ui/Button'; import { Input } from './ui/Input'; import { Switch } from './ui/Switch'; -import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download } from 'lucide-react'; +import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download, Trash2 } from 'lucide-react'; import type { AccessList, AccessListRule } from '../api/accessLists'; import { SECURITY_PRESETS, calculateTotalIPs, formatIPCount, type SecurityPreset } from '../data/securityPresets'; import { getMyIP } from '../api/system'; @@ -12,7 +12,9 @@ interface AccessListFormProps { initialData?: AccessList; onSubmit: (data: AccessListFormData) => void; onCancel: () => void; + onDelete?: () => void; isLoading?: boolean; + isDeleting?: boolean; } export interface AccessListFormData { @@ -68,7 +70,7 @@ const COUNTRIES = [ { code: 'VN', name: 'Vietnam' }, ]; -export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: AccessListFormProps) { +export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLoading, isDeleting }: AccessListFormProps) { const [formData, setFormData] = useState({ name: initialData?.name || '', description: initialData?.description || '', @@ -269,11 +271,11 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A Quick-start templates based on threat intelligence feeds and best practices. Hover over (i) for data sources.

- {/* Security Category */} + {/* Security Category - filter by current type */}

Recommended Security Presets

- {SECURITY_PRESETS.filter(p => p.category === 'security').map((preset) => ( + {SECURITY_PRESETS.filter(p => p.category === 'security' && p.type === formData.type).map((preset) => (
- {/* Advanced Category */} + {/* Advanced Category - filter by current type */}

Advanced Presets

- {SECURITY_PRESETS.filter(p => p.category === 'advanced').map((preset) => ( + {SECURITY_PRESETS.filter(p => p.category === 'advanced' && p.type === formData.type).map((preset) => (
- - +
+
+ {initialData && onDelete && ( + + )} +
+
+ + +
); diff --git a/frontend/src/pages/AccessLists.tsx b/frontend/src/pages/AccessLists.tsx index a28ed47a..687ddf57 100644 --- a/frontend/src/pages/AccessLists.tsx +++ b/frontend/src/pages/AccessLists.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Button } from '../components/ui/Button'; -import { Plus, Pencil, Trash2, TestTube2, ExternalLink, AlertTriangle } from 'lucide-react'; +import { Plus, Pencil, Trash2, TestTube2, ExternalLink, AlertTriangle, CheckSquare, Square } from 'lucide-react'; import { useAccessLists, useCreateAccessList, @@ -10,8 +10,47 @@ import { } from '../hooks/useAccessLists'; import { AccessListForm, type AccessListFormData } from '../components/AccessListForm'; import type { AccessList } from '../api/accessLists'; +import { createBackup } from '../api/backups'; import toast from 'react-hot-toast'; +// Confirmation Dialog Component +function ConfirmDialog({ + isOpen, + title, + message, + confirmLabel, + onConfirm, + onCancel, + isLoading, +}: { + isOpen: boolean; + title: string; + message: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; + isLoading?: boolean; +}) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +

{title}

+

{message}

+
+ + +
+
+
+ ); +} + export default function AccessLists() { const { data: accessLists, isLoading } = useAccessLists(); const createMutation = useCreateAccessList(); @@ -25,6 +64,12 @@ export default function AccessLists() { const [testIP, setTestIP] = useState(''); const [showCGNATWarning, setShowCGNATWarning] = useState(true); + // Selection state for bulk operations + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const handleCreate = (data: AccessListFormData) => { createMutation.mutate(data, { onSuccess: () => setShowCreateForm(false), @@ -41,9 +86,64 @@ export default function AccessLists() { ); }; - const handleDelete = (acl: AccessList) => { - if (!confirm(`Delete "${acl.name}"? This cannot be undone.`)) return; - deleteMutation.mutate(acl.id); + const handleDeleteWithBackup = async (acl: AccessList) => { + setIsDeleting(true); + try { + // Create backup before deletion + toast.loading('Creating backup before deletion...', { id: 'backup-toast' }); + await createBackup(); + toast.success('Backup created', { id: 'backup-toast' }); + + // Now delete + deleteMutation.mutate(acl.id, { + onSuccess: () => { + setShowDeleteConfirm(null); + setEditingACL(null); + toast.success(`"${acl.name}" deleted. A backup was created before deletion.`); + }, + onError: (error) => { + toast.error(`Failed to delete: ${error.message}`); + }, + onSettled: () => { + setIsDeleting(false); + }, + }); + } catch { + toast.error('Failed to create backup', { id: 'backup-toast' }); + setIsDeleting(false); + } + }; + + const handleBulkDeleteWithBackup = async () => { + if (selectedIds.size === 0) return; + + setIsDeleting(true); + try { + // Create backup before deletion + toast.loading('Creating backup before bulk deletion...', { id: 'backup-toast' }); + await createBackup(); + toast.success('Backup created', { id: 'backup-toast' }); + + // Delete each selected ACL + const deletePromises = Array.from(selectedIds).map( + (id) => + new Promise((resolve, reject) => { + deleteMutation.mutate(id, { + onSuccess: () => resolve(), + onError: (error) => reject(error), + }); + }) + ); + + await Promise.all(deletePromises); + setSelectedIds(new Set()); + setShowBulkDeleteConfirm(false); + toast.success(`${selectedIds.size} access list(s) deleted. A backup was created before deletion.`); + } catch { + toast.error('Failed to delete some items'); + } finally { + setIsDeleting(false); + } }; const handleTestIP = () => { @@ -63,6 +163,25 @@ export default function AccessLists() { ); }; + const toggleSelectAll = () => { + if (!accessLists) return; + if (selectedIds.size === accessLists.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(accessLists.map((acl) => acl.id))); + } + }; + + const toggleSelect = (id: number) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + const getRulesDisplay = (acl: AccessList) => { if (acl.local_network_only) { return 🏠 RFC1918 Only; @@ -193,11 +312,35 @@ export default function AccessLists() { initialData={editingACL} onSubmit={handleUpdate} onCancel={() => setEditingACL(null)} + onDelete={() => setShowDeleteConfirm(editingACL)} isLoading={updateMutation.isPending} + isDeleting={isDeleting} />
)} + {/* Delete Confirmation Dialog */} + showDeleteConfirm && handleDeleteWithBackup(showDeleteConfirm)} + onCancel={() => setShowDeleteConfirm(null)} + isLoading={isDeleting} + /> + + {/* Bulk Delete Confirmation Dialog */} + setShowBulkDeleteConfirm(false)} + isLoading={isDeleting} + /> + {/* Test IP Modal */} {testingACL && (
setTestingACL(null)}> @@ -238,9 +381,39 @@ export default function AccessLists() { {/* Table */} {accessLists && accessLists.length > 0 && !showCreateForm && !editingACL && (
+ {/* Bulk Actions Bar */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} item(s) selected + + +
+ )} + @@ -250,7 +423,19 @@ export default function AccessLists() { {accessLists.map((acl) => ( - + +
+ + Name Type Rules
+ +

{acl.name}

@@ -290,10 +475,10 @@ export default function AccessLists() {