diff --git a/backend/internal/api/handlers/system_handler.go b/backend/internal/api/handlers/system_handler.go new file mode 100644 index 00000000..8f369e6a --- /dev/null +++ b/backend/internal/api/handlers/system_handler.go @@ -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" +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index b6222d69..2c077f98 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -114,6 +114,10 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { updateHandler := handlers.NewUpdateHandler(updateService) protected.GET("/system/updates", updateHandler.Check) + // System info + systemHandler := handlers.NewSystemHandler() + protected.GET("/system/my-ip", systemHandler.GetMyIP) + // Notifications notificationHandler := handlers.NewNotificationHandler(notificationService) protected.GET("/notifications", notificationHandler.List) diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 43636412..5e5e38f0 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -32,3 +32,13 @@ export const markNotificationRead = async (id: string): Promise => { export const markAllNotificationsRead = async (): Promise => { await client.post('/notifications/read-all'); }; + +export interface MyIPResponse { + ip: string; + source: string; +} + +export const getMyIP = async (): Promise => { + const response = await client.get('/system/my-ip'); + return response.data; +}; diff --git a/frontend/src/components/AccessListForm.tsx b/frontend/src/components/AccessListForm.tsx index 90b421fb..1edd0804 100644 --- a/frontend/src/components/AccessListForm.tsx +++ b/frontend/src/components/AccessListForm.tsx @@ -2,8 +2,11 @@ import { useState } from 'react'; import { Button } from './ui/Button'; import { Input } from './ui/Input'; import { Switch } from './ui/Switch'; -import { X, Plus, ExternalLink } from 'lucide-react'; +import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download } 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'; +import toast from 'react-hot-toast'; interface AccessListFormProps { initialData?: AccessList; @@ -96,10 +99,17 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A const [newIP, setNewIP] = useState(''); const [newIPDescription, setNewIPDescription] = useState(''); + const [showPresets, setShowPresets] = useState(false); + const [loadingMyIP, setLoadingMyIP] = useState(false); const isGeoType = formData.type.startsWith('geo_'); const isIPType = !isGeoType; + // Calculate total IPs in current rules + const totalIPs = isIPType && !formData.local_network_only + ? calculateTotalIPs(ipRules.map(r => r.cidr)) + : 0; + const handleAddIP = () => { if (!newIP.trim()) return; @@ -128,6 +138,35 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A setSelectedCountries(selectedCountries.filter((c) => c !== countryCode)); }; + const handleApplyPreset = (preset: SecurityPreset) => { + if (preset.type === 'geo_blacklist' && preset.countryCodes) { + setFormData({ ...formData, type: 'geo_blacklist' }); + setSelectedCountries([...new Set([...selectedCountries, ...preset.countryCodes])]); + toast.success(`Applied preset: ${preset.name}`); + } else if (preset.type === 'blacklist' && preset.ipRanges) { + setFormData({ ...formData, type: 'blacklist' }); + const newRules = preset.ipRanges.filter( + (newRule) => !ipRules.some((existing) => existing.cidr === newRule.cidr) + ); + setIPRules([...ipRules, ...newRules]); + toast.success(`Applied preset: ${preset.name} (${newRules.length} rules added)`); + } + setShowPresets(false); + }; + + const handleGetMyIP = async () => { + setLoadingMyIP(true); + try { + const result = await getMyIP(); + setNewIP(result.ip); + toast.success(`Your IP: ${result.ip} (from ${result.source})`); + } catch (error) { + toast.error('Failed to fetch your IP address'); + } finally { + setLoadingMyIP(false); + } + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -192,12 +231,149 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" > - + - + + {(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && ( +
+ +

+ Recommended: Block lists are safer than allow lists. They block known bad actors while allowing everyone else access, preventing lockouts. +

+
+ )} + {/* Security Presets */} + {(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && ( +
+
+
+ +

Security Presets

+
+ +
+ + {showPresets && ( +
+

+ Quick-start templates based on threat intelligence feeds and best practices. Hover over (i) for data sources. +

+ + {/* Security Category */} +
+

Recommended Security Presets

+
+ {SECURITY_PRESETS.filter(p => p.category === 'security').map((preset) => ( +
+
+
+
+
{preset.name}
+ + + +
+

{preset.description}

+
+ ~{preset.estimatedIPs} IPs + | + {preset.dataSource} +
+ {preset.warning && ( +
+ + {preset.warning} +
+ )} +
+ +
+
+ ))} +
+
+ + {/* Advanced Category */} +
+

Advanced Presets

+
+ {SECURITY_PRESETS.filter(p => p.category === 'advanced').map((preset) => ( +
+
+
+
+
{preset.name}
+ + + +
+

{preset.description}

+
+ ~{preset.estimatedIPs} IPs + | + {preset.dataSource} +
+ {preset.warning && ( +
+ + {preset.warning} +
+ )} +
+ +
+
+ ))} +
+
+
+ )} +
+ )} +
@@ -231,7 +407,20 @@ export function AccessListForm({ initialData, onSubmit, onCancel, isLoading }: A {!formData.local_network_only && ( <>
- +
+ + +
+ {totalIPs > 0 && ( +
+ + Current rules cover approximately {formatIPCount(totalIPs)} IP addresses +
+ )}
{ipRules.length > 0 && ( diff --git a/frontend/src/data/securityPresets.ts b/frontend/src/data/securityPresets.ts new file mode 100644 index 00000000..31753729 --- /dev/null +++ b/frontend/src/data/securityPresets.ts @@ -0,0 +1,169 @@ +/** + * Security Presets for Access Control Lists + * + * Data sources: + * - High-risk countries: Based on common attack origin statistics from threat intelligence feeds + * - Cloud scanner IPs: Known IP ranges used for mass scanning (Shodan, Censys, etc.) + * - Botnet IPs: Curated from public blocklists (Spamhaus, abuse.ch, etc.) + * + * References: + * - SANS Internet Storm Center: https://isc.sans.edu/ + * - Spamhaus DROP/EDROP lists: https://www.spamhaus.org/drop/ + * - Abuse.ch threat feeds: https://abuse.ch/ + */ + +export interface SecurityPreset { + id: string; + name: string; + description: string; + category: 'security' | 'advanced'; + type: 'geo_blacklist' | 'blacklist'; + countryCodes?: string[]; + ipRanges?: Array<{ cidr: string; description: string }>; + estimatedIPs: string; + dataSource: string; + dataSourceUrl: string; + warning?: string; +} + +export const SECURITY_PRESETS: SecurityPreset[] = [ + { + id: 'high-risk-countries', + name: 'Block High-Risk Countries', + description: 'Block countries with highest attack/spam rates', + category: 'security', + type: 'geo_blacklist', + countryCodes: [ + 'RU', // Russia + 'CN', // China + 'KP', // North Korea + 'IR', // Iran + 'BY', // Belarus + 'SY', // Syria + 'VE', // Venezuela + 'CU', // Cuba + 'SD', // Sudan + ], + estimatedIPs: '~800 million', + dataSource: 'SANS ISC Top Attack Origins', + dataSourceUrl: 'https://isc.sans.edu/sources.html', + warning: 'This blocks entire countries. Legitimate users from these countries will be blocked.', + }, + { + id: 'expanded-threat-countries', + name: 'Block Expanded Threat List', + description: 'Includes high-risk countries plus additional threat sources', + category: 'security', + type: 'geo_blacklist', + countryCodes: [ + 'RU', // Russia + 'CN', // China + 'KP', // North Korea + 'IR', // Iran + 'BY', // Belarus + 'SY', // Syria + 'VE', // Venezuela + 'CU', // Cuba + 'SD', // Sudan + 'PK', // Pakistan + 'BD', // Bangladesh + 'NG', // Nigeria + 'UA', // Ukraine (unfortunately high bot activity) + 'VN', // Vietnam + 'ID', // Indonesia + ], + estimatedIPs: '~1.2 billion', + dataSource: 'Combined threat intelligence feeds', + dataSourceUrl: 'https://isc.sans.edu/', + warning: 'This is aggressive blocking. May impact legitimate international users.', + }, + { + id: 'cloud-scanners', + name: 'Block Cloud Scanner IPs', + description: 'Block IP ranges used by mass scanning services', + category: 'advanced', + type: 'blacklist', + ipRanges: [ + // Shodan scanning IPs (examples - real implementation should use current list) + { cidr: '71.6.135.0/24', description: 'Shodan scanners' }, + { cidr: '71.6.167.0/24', description: 'Shodan scanners' }, + { cidr: '82.221.105.0/24', description: 'Shodan scanners' }, + { cidr: '85.25.43.0/24', description: 'Shodan scanners' }, + { cidr: '85.25.103.0/24', description: 'Shodan scanners' }, + { cidr: '93.120.27.0/24', description: 'Shodan scanners' }, + { cidr: '162.142.125.0/24', description: 'Censys scanners' }, + { cidr: '167.248.133.0/24', description: 'Censys scanners' }, + { cidr: '198.108.66.0/24', description: 'Shodan scanners' }, + { cidr: '198.20.69.0/24', description: 'Shodan scanners' }, + ], + estimatedIPs: '~3,000', + dataSource: 'Shodan/Censys official scanner lists', + dataSourceUrl: 'https://help.shodan.io/the-basics/what-is-shodan', + warning: 'Only blocks known scanner IPs. New scanner IPs may not be included.', + }, + { + id: 'tor-exit-nodes', + name: 'Block Tor Exit Nodes', + description: 'Block known Tor network exit nodes', + category: 'advanced', + type: 'blacklist', + ipRanges: [ + // Note: Tor exit nodes change frequently + // Real implementation should fetch from https://check.torproject.org/exit-addresses + { cidr: '185.220.100.0/22', description: 'Tor exit nodes' }, + { cidr: '185.220.101.0/24', description: 'Tor exit nodes' }, + { cidr: '185.220.102.0/24', description: 'Tor exit nodes' }, + { cidr: '185.100.84.0/22', description: 'Tor exit nodes' }, + { cidr: '185.100.86.0/24', description: 'Tor exit nodes' }, + { cidr: '185.100.87.0/24', description: 'Tor exit nodes' }, + ], + estimatedIPs: '~1,200 (changes daily)', + dataSource: 'Tor Project Exit Node List', + dataSourceUrl: 'https://check.torproject.org/exit-addresses', + warning: 'Tor exit nodes change frequently. Consider using a dynamic blocklist service.', + }, +]; + +export const getPresetById = (id: string): SecurityPreset | undefined => { + return SECURITY_PRESETS.find((preset) => preset.id === id); +}; + +export const getPresetsByCategory = (category: 'security' | 'advanced'): SecurityPreset[] => { + return SECURITY_PRESETS.filter((preset) => preset.category === category); +}; + +/** + * Calculate approximate number of IPs in a CIDR range + */ +export const calculateCIDRSize = (cidr: string): number => { + const parts = cidr.split('/'); + if (parts.length !== 2) return 1; + + const bits = parseInt(parts[1], 10); + if (isNaN(bits) || bits < 0 || bits > 32) return 1; + + return Math.pow(2, 32 - bits); +}; + +/** + * Format IP count for display + */ +export const formatIPCount = (count: number): string => { + if (count >= 1000000000) { + return `${(count / 1000000000).toFixed(1)}B`; + } + if (count >= 1000000) { + return `${(count / 1000000).toFixed(1)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(1)}K`; + } + return count.toString(); +}; + +/** + * Calculate total IPs in a list of CIDR ranges + */ +export const calculateTotalIPs = (cidrs: string[]): number => { + return cidrs.reduce((total, cidr) => total + calculateCIDRSize(cidr), 0); +}; diff --git a/frontend/src/pages/ProxyHosts.tsx b/frontend/src/pages/ProxyHosts.tsx index 3e7fb210..6ac38673 100644 --- a/frontend/src/pages/ProxyHosts.tsx +++ b/frontend/src/pages/ProxyHosts.tsx @@ -306,6 +306,8 @@ export default function ProxyHosts() {