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
+
+
+
+ )}
+ |
+
+ |
Name |
Type |
Rules |
@@ -250,7 +423,19 @@ export default function AccessLists() {
{accessLists.map((acl) => (
-
+
+ |
+
+ |
{acl.name}
@@ -290,10 +475,10 @@ export default function AccessLists() {
|