Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/Statistics.php

3735 lines
156 KiB
PHP

<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Carbon\Carbon;
use IcehouseVentures\LaravelChartjs\Facades\Chartjs;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Models\ServerStatistics;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
use Paymenter\Extensions\Servers\Proxmox\Services\CoreRouterClient;
class Statistics extends Component
{
public Server $server;
public $selectedStatisticId = null;
public $statisticToDeleteId = null;
public $showDeleteModal = false;
public $expandedTaskUpids = [];
public $showClearStatisticsModal = false;
public $showClearTaskHistoryModal = false;
public string $cpuTimeRange = 'hour';
public string $cpuAggregation = 'average';
public string $memoryTimeRange = 'hour';
public string $memoryAggregation = 'average';
public string $networkTimeRange = 'hour';
public string $networkAggregation = 'average';
public string $diskTimeRange = 'hour';
public string $diskAggregation = 'average';
public bool $showExportInfoModal = false;
public string $exportInfoMessage = '';
public array $graphPayloads = [
'cpu' => [],
'memory' => [],
'network' => [],
'disk' => [],
];
public bool $shouldRenderCharts = false;
public bool $networkAnalysisLoading = false;
public int $networkAnalysisRetryCount = 0;
public int $networkAnalysisMaxRetries = 3;
public array $networkAnalysis = [
'connections' => [],
'topConnections' => [],
'topPorts' => [],
'security' => [],
'lastUpdated' => null,
'error' => null,
];
protected array $cachedServerIps = [];
protected array $serverIpCidrs = [];
// Historical comparison settings
public string $historicalPeriod = '1_hour_ago'; // Options: 1_hour_ago, 2_hours_ago, 6_hours_ago, 12_hours_ago, 24_hours_ago, custom
public string $currentPeriod = 'now'; // Options: now, 1_hour_ago, custom
public ?string $historicalCustomDate = null;
public ?string $currentCustomDate = null;
public function getChartEventsProperty()
{
$tasks = $this->taskHistory;
$events = [];
foreach ($tasks as $task) {
$type = strtolower($task['type'] ?? '');
$status = strtolower($task['status'] ?? '');
$startTime = $task['starttime'] ?? null;
$endTime = $task['endtime'] ?? null;
$upid = $task['upid'] ?? $task['id'] ?? null;
if (!$startTime) {
continue;
}
$event = null;
if (in_array($type, ['qmstart', 'qmresume'])) {
$event = ['label' => 'Start', 'type' => 'start', 'icon' => 'ri-play-fill'];
} elseif (in_array($type, ['qmstop', 'qmshutdown'])) {
$event = ['label' => 'Shutdown', 'type' => 'shutdown', 'icon' => 'ri-stop-fill'];
} elseif (in_array($type, ['qmreboot'])) {
$event = ['label' => 'Reboot', 'type' => 'reboot', 'icon' => 'ri-loop-left-line'];
} elseif (in_array($type, ['qmreset'])) {
$event = ['label' => 'Reset', 'type' => 'reset', 'icon' => 'ri-restart-line'];
} elseif ($type === 'vzdump') {
$event = ['label' => 'Backup', 'type' => 'backup', 'icon' => 'ri-save-3-fill'];
}
if ($event) {
$startCarbon = \Carbon\Carbon::createFromTimestamp($startTime);
$endCarbon = $endTime && $endTime > 0 ? \Carbon\Carbon::createFromTimestamp($endTime) : null;
$duration = null;
if ($startCarbon && $endCarbon) {
$duration = $startCarbon->diffInSeconds($endCarbon);
} elseif ($startCarbon && $status === 'running') {
$duration = $startCarbon->diffInSeconds(now());
}
$events[] = [
'timestamp' => $startTime * 1000, // Convert to milliseconds
'label' => $event['label'],
'type' => $event['type'],
'icon' => $event['icon'],
'status' => $status,
'original_type' => $task['type'] ?? 'N/A',
'starttime' => $startTime,
'endtime' => $endTime,
'duration' => $duration,
'upid' => $upid,
'start_datetime' => $startCarbon->format('M d, Y H:i:s'),
'end_datetime' => $endCarbon ? $endCarbon->format('M d, Y H:i:s') : null,
'duration_formatted' => $duration !== null ? $this->formatUptime($duration) : null,
];
}
}
return $events;
}
public function mount(Server $server): void
{
parent::mount($server);
$this->server->loadMissing('ipAddresses');
// Network Analysis is loaded lazily via JavaScript to improve page load speed
// Initialize error state if not configured to prevent infinite loading
if (!$this->coreRouterEnabled()) {
$this->networkAnalysis['error'] = 'Core Router API is not configured.';
$this->networkAnalysis['connections'] = [];
$this->networkAnalysis['topConnections'] = [];
$this->networkAnalysis['topPorts'] = [];
$this->networkAnalysis['security'] = [];
}
}
#[On('proxmox:stats')]
public function stats(): void
{
$this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation);
$this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation);
$this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation);
$this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation);
}
public function loadNetworkAnalysis(): void
{
Log::debug('Network Analysis: loadNetworkAnalysis() called', [
'current_loading_state' => $this->networkAnalysisLoading,
'has_last_updated' => ! empty($this->networkAnalysis['lastUpdated']),
'has_error' => ! empty($this->networkAnalysis['error']),
]);
// Use a short-lived cache to avoid repeatedly performing heavy analysis
$cacheKey = 'proxmox_network_analysis_' . $this->server->id;
// If we already have a fairly recent result, reuse it immediately
$cachedAnalysis = Cache::get($cacheKey);
if (is_array($cachedAnalysis) && ! empty($cachedAnalysis['lastUpdated']) && empty($this->networkAnalysis['error'])) {
Log::debug('Network Analysis: served from cache', [
'cache_key' => $cacheKey,
'last_updated' => $cachedAnalysis['lastUpdated'] ?? null,
]);
$this->networkAnalysis = $cachedAnalysis;
$this->networkAnalysisLoading = false;
// Reset retry count when we successfully hydrate from cache
$this->networkAnalysisRetryCount = 0;
return;
}
// Prevent duplicate loads while a request is already in-flight
if ($this->networkAnalysisLoading) {
Log::debug('Network Analysis: Already loading, skipping new request');
return;
}
if (! $this->coreRouterEnabled()) {
$this->networkAnalysis['error'] = 'Core Router API is not configured.';
$this->networkAnalysis['connections'] = [];
$this->networkAnalysis['topConnections'] = [];
$this->networkAnalysis['topPorts'] = [];
$this->networkAnalysis['security'] = [];
$this->networkAnalysisLoading = false;
return;
}
$serverIps = $this->getServerIps();
if (empty($serverIps)) {
$this->networkAnalysis['error'] = 'No IP addresses are associated with this server.';
$this->networkAnalysis['connections'] = [];
$this->networkAnalysis['topConnections'] = [];
$this->networkAnalysis['topPorts'] = [];
$this->networkAnalysis['security'] = [];
$this->networkAnalysisLoading = false;
return;
}
$this->networkAnalysisLoading = true;
$this->networkAnalysis['error'] = null;
try {
$client = app(CoreRouterClient::class);
// Fetch all firewall states (no API-level filtering)
$allStates = $client->listStates();
// Normalize server IPs for matching
$normalizedServerIps = array_map(static function ($ip) {
return strtolower(trim($ip));
}, $serverIps);
// Sample a few states to debug extraction (kept minimal for performance)
$sampleStatesForDebug = [];
foreach (array_slice($allStates, 0, 10) as $sampleState) {
$rawSrc = (string) ($sampleState['source'] ?? '');
$rawDst = (string) ($sampleState['destination'] ?? '');
$sampleStatesForDebug[] = [
'raw_source' => $rawSrc,
'raw_destination' => $rawDst,
'extracted_source' => $this->extractIpFromField($rawSrc),
'extracted_destination' => $this->extractIpFromField($rawDst),
'protocol' => $sampleState['protocol'] ?? 'N/A',
];
}
Log::debug('Core Router filtering states', [
'total_states' => count($allStates),
'server_ips' => $serverIps,
'normalized_server_ips' => $normalizedServerIps,
'sample_states_extraction' => $sampleStatesForDebug,
]);
// Filter states: keep only where source OR destination matches a server IP
$filteredStates = [];
$sampleChecks = [];
foreach ($allStates as $state) {
$rawSource = (string) ($state['source'] ?? '');
$rawDestination = (string) ($state['destination'] ?? '');
// Extract IP from "IP[port]" or "IP:port" format
$sourceIp = $this->extractIpFromField($rawSource);
$destinationIp = $this->extractIpFromField($rawDestination);
$sourceNormalized = strtolower(trim($sourceIp));
$destNormalized = strtolower(trim($destinationIp));
$matches = false;
$matchReason = '';
// Normalize raw fields for case-insensitive matching
$rawSourceLower = strtolower($rawSource);
$rawDestinationLower = strtolower($rawDestination);
// Check exact match
foreach ($normalizedServerIps as $serverIp) {
if ($sourceNormalized === $serverIp) {
$matches = true;
$matchReason = "source exact match: {$sourceNormalized} === {$serverIp}";
break;
}
if ($destNormalized === $serverIp) {
$matches = true;
$matchReason = "destination exact match: {$destNormalized} === {$serverIp}";
break;
}
// Also check raw fields contain the IP (handles "IP[port]" format) - case-insensitive
if (stripos($rawSourceLower, $serverIp) !== false) {
$matches = true;
$matchReason = "raw source contains: {$rawSource} contains {$serverIp}";
break;
}
if (stripos($rawDestinationLower, $serverIp) !== false) {
$matches = true;
$matchReason = "raw destination contains: {$rawDestination} contains {$serverIp}";
break;
}
}
if ($matches) {
$filteredStates[] = $state;
// Log first few matches for debugging
if (count($sampleChecks) < 5) {
$sampleChecks[] = [
'raw_source' => $rawSource,
'raw_destination' => $rawDestination,
'extracted_source' => $sourceIp,
'extracted_destination' => $destinationIp,
'source_normalized' => $sourceNormalized,
'dest_normalized' => $destNormalized,
'match_reason' => $matchReason,
'protocol' => $state['protocol'] ?? 'N/A',
];
}
}
}
Log::debug('Core Router filtered states result', [
'filtered_count' => count($filteredStates),
'sample_matches' => $sampleChecks,
]);
// Format connections
$connections = $this->formatConnectionsSimple(array_values($filteredStates));
$topConnections = $this->aggregateTopConnections($connections);
$topPorts = $this->aggregateTopPorts($connections);
$securityWarnings = $this->checkSecurityThreats($connections);
Log::debug('Core Router network analysis - final formatting', [
'filtered_states_count' => count($filteredStates),
'connections_count' => count($connections),
'sample_connections' => array_slice($connections, 0, 3),
]);
$analysis = [
'connections' => array_slice($connections, 0, 100),
'topConnections' => array_slice($topConnections, 0, 10),
'topPorts' => array_slice($topPorts, 0, 10),
'security' => $securityWarnings,
'lastUpdated' => now()->toDateTimeString(),
'error' => null,
];
$this->networkAnalysis = $analysis;
// Persist the computed analysis for a short period to speed up subsequent loads
Cache::put($cacheKey, $analysis, now()->addSeconds(30));
// Reset retry count on successful load
$this->networkAnalysisRetryCount = 0;
} catch (\Throwable $e) {
report($e);
$errorMessage = $e->getMessage();
$is502Error = str_contains($errorMessage, 'HTTP 502') || str_contains($errorMessage, '(HTTP 502)');
// Always initialize error arrays first
$this->networkAnalysis['connections'] = [];
$this->networkAnalysis['topConnections'] = [];
$this->networkAnalysis['topPorts'] = [];
$this->networkAnalysis['security'] = [];
// On failure, do not leave any stale cache behind for this server
Cache::forget($cacheKey);
// Check if it's a 502 error and we haven't exceeded max retries
if ($is502Error && $this->networkAnalysisRetryCount < $this->networkAnalysisMaxRetries) {
$this->networkAnalysisRetryCount++;
Log::warning('Core Router API returned 502 error, will retry', [
'retry_count' => $this->networkAnalysisRetryCount,
'max_retries' => $this->networkAnalysisMaxRetries,
]);
// Set error message indicating retry
$this->networkAnalysis['error'] = 'Temporary server error (HTTP 502). Retrying... (Attempt ' . $this->networkAnalysisRetryCount . '/' . $this->networkAnalysisMaxRetries . ')';
// Dispatch event to retry after a delay
$this->dispatch('network-analysis-502-retry', [
'retryCount' => $this->networkAnalysisRetryCount,
]);
} else {
// Not a 502 error or max retries reached - show final error
$this->networkAnalysisRetryCount = 0;
$this->networkAnalysis['error'] = 'Unable to load network analysis: ' . $errorMessage;
}
} finally {
// Always clear loading state in finally block so UI can recover
$this->networkAnalysisLoading = false;
}
}
/**
* Retry loading network analysis after a 502 error
*/
public function retryNetworkAnalysis(): void
{
// Simple helper that can be invoked from Livewire/JS if needed.
// We intentionally avoid blocking sleep here; the browser-side
// retry logic already introduces a delay between attempts.
$this->networkAnalysisLoading = false;
$this->loadNetworkAnalysis();
}
/**
* Extract IP address from field (handles "IP[port]" and "IP:port" formats)
*/
protected function extractIpFromField(string $field): string
{
if (empty($field)) {
return '';
}
$field = trim($field);
// Handle IPv4 "IP:port" format FIRST (e.g., "45.154.99.28:22")
// Check this before the bracket format since IPv4 also uses colons
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) {
return $matches[1];
}
// Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]")
// This regex matches: IP part (anything that's not [ or ]) optionally followed by [port]
if (preg_match('/^([^\[\]]+)(?:\[[^\]]+\])?$/', $field, $matches)) {
$ip = trim($matches[1]);
// Remove any trailing colons or brackets
$ip = rtrim($ip, ':[]');
return $ip;
}
// If no special format, return as-is
return $field;
}
/**
* Simple connection formatting
*/
protected function formatConnectionsSimple(array $states): array
{
$connections = [];
foreach ($states as $state) {
$rawSource = (string)($state['source'] ?? '');
$rawDestination = (string)($state['destination'] ?? '');
$sourceIp = $this->extractIpFromField($rawSource);
$destinationIp = $this->extractIpFromField($rawDestination);
// Extract ports
$sourcePort = null;
$destinationPort = null;
if (preg_match('/\[(\d+)\]$/', $rawSource, $matches)) {
$sourcePort = (int)$matches[1];
} elseif (preg_match('/:(\d+)$/', $rawSource, $matches) && filter_var(explode(':', $rawSource)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$sourcePort = (int)$matches[1];
}
if (preg_match('/\[(\d+)\]$/', $rawDestination, $matches)) {
$destinationPort = (int)$matches[1];
} elseif (preg_match('/:(\d+)$/', $rawDestination, $matches) && filter_var(explode(':', $rawDestination)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$destinationPort = (int)$matches[1];
}
$bytes = (int)($state['bytes_total'] ?? $state['bytes'] ?? 0);
$packets = (int)($state['packets_total'] ?? $state['packets'] ?? 0);
$protocol = strtoupper($state['protocol'] ?? 'N/A');
$stateName = strtoupper($state['state'] ?? 'UNKNOWN');
$interface = strtoupper($state['interface'] ?? 'N/A');
$direction = strtolower($state['direction'] ?? '') === 'in' ? 'Inbound' : 'Outbound';
$connections[] = [
'protocol' => $protocol,
'source' => $sourceIp,
'destination' => $destinationIp,
'source_port' => $sourcePort,
'destination_port' => $destinationPort,
'raw_source' => $rawSource,
'raw_destination' => $rawDestination,
'bytes' => $bytes,
'bytes_human' => $this->formatBytes($bytes),
'packets' => $packets,
'state' => $stateName,
'interface' => $interface,
'direction' => $direction,
];
}
usort($connections, fn($a, $b) => $b['bytes'] <=> $a['bytes']);
return $connections;
}
protected function fetchGraphData(string $graphType, string $timeRange, string $aggregation): void
{
$cacheKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation;
try {
$data = Cache::remember($cacheKey, 60, function () use ($timeRange, $aggregation) {
$proxmox = new Proxmox;
$stats = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/rrddata', data: [
'timeframe' => $timeRange,
'cf' => $aggregation === 'maximum' ? 'MAX' : 'AVERAGE',
]);
$rawData = $stats->json()['data'] ?? [];
// Limit data points to prevent memory issues and 413 errors
// Always limit to 40 points regardless of time range
$maxPoints = 40;
// Sample data if it exceeds max points
if (count($rawData) > $maxPoints) {
$step = ceil(count($rawData) / $maxPoints);
$sampledData = [];
for ($i = 0; $i < count($rawData); $i += $step) {
$sampledData[] = $rawData[$i];
}
// Always include the last point
if (end($rawData) !== end($sampledData)) {
$sampledData[] = end($rawData);
}
return $sampledData;
}
return $rawData;
});
} catch (\Throwable $e) {
report($e);
$data = [];
}
$this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $data);
}
#[On('render-chart-events')]
public function renderChartEvents()
{
$this->dispatch('proxmox:chart-events', $this->chartEvents);
}
public function updatedCpuTimeRange(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation);
$this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation);
}
public function updatedCpuAggregation(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation);
$this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation);
}
public function updatedMemoryTimeRange(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation);
$this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation);
}
public function updatedMemoryAggregation(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation);
$this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation);
}
public function updatedNetworkTimeRange(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation);
$this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation);
}
public function updatedNetworkAggregation(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation);
$this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation);
}
public function updatedDiskTimeRange(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation);
$this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation);
}
public function updatedDiskAggregation(): void
{
Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation);
$this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation);
}
public function showExportInfo(string $message): void
{
$this->exportInfoMessage = $message;
$this->showExportInfoModal = true;
}
public function closeExportInfoModal(): void
{
$this->showExportInfoModal = false;
}
public function openDeleteModal($statisticId)
{
$this->statisticToDeleteId = $statisticId;
$this->showDeleteModal = true;
}
public function closeDeleteModal()
{
$this->showDeleteModal = false;
$this->statisticToDeleteId = null;
}
public function selectStatistic($statisticId)
{
$this->selectedStatisticId = $statisticId;
$this->dispatch('statistic-selected');
}
public function createLiveStatistic()
{
try {
// Check if user has reached the maximum of 5 manual statistics
$manualStatsCount = ServerStatistics::where('server_id', $this->server->id)
->where('is_automatic', false)
->count();
if ($manualStatsCount >= 5) {
$this->notify('Maximum limit of 5 manual statistics reached. Please delete an existing manual statistic before creating a new one.', 'error');
return;
}
$proxmox = new Proxmox;
$status = $proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/current'
)->json()['data'] ?? null;
if ($status) {
// Get disk I/O from rrddata (latest point)
$diskRead = 0;
$diskWrite = 0;
$diskUsed = $status['disk'] ?? 0;
$diskMax = $status['maxdisk'] ?? 0;
try {
$rrdData = $proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/rrddata',
'get',
['timeframe' => 'hour', 'cf' => 'AVERAGE']
)->json()['data'] ?? null;
if ($rrdData && is_array($rrdData) && count($rrdData) > 0) {
$latest = end($rrdData);
$diskRead = (int)($latest['diskread'] ?? 0);
$diskWrite = (int)($latest['diskwrite'] ?? 0);
}
} catch (\Throwable $e) {
// If rrddata fails, continue with 0 values
}
// Always try to get diskmax from VM config (more reliable than status endpoint)
// The status endpoint may not return maxdisk, especially if guest agent is not enabled
try {
$config = $proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config'
)->json()['data'] ?? null;
if ($config) {
// Try to find disk configuration (scsi0, virtio0, sata0, ide0, scsi1, etc.)
$diskKeys = ['scsi0', 'scsi1', 'scsi2', 'virtio0', 'virtio1', 'virtio2', 'sata0', 'sata1', 'sata2', 'ide0', 'ide1', 'ide2'];
$totalDiskSize = 0;
foreach ($diskKeys as $diskKey) {
if (isset($config[$diskKey])) {
// Parse disk size from format like "local-lvm:vm-100-disk-0,size=32G"
$diskConfig = $config[$diskKey];
if (preg_match('/size=(\d+)([TGMK]?)/i', $diskConfig, $matches)) {
$size = (int)$matches[1];
$unit = strtoupper($matches[2] ?? 'G');
// Convert to bytes
switch ($unit) {
case 'T':
$totalDiskSize += $size * 1024 * 1024 * 1024 * 1024;
break;
case 'G':
$totalDiskSize += $size * 1024 * 1024 * 1024;
break;
case 'M':
$totalDiskSize += $size * 1024 * 1024;
break;
case 'K':
$totalDiskSize += $size * 1024;
break;
default:
$totalDiskSize += $size * 1024 * 1024 * 1024; // Default to GB
}
}
}
}
// Use config disk size if we found it, otherwise keep status value
if ($totalDiskSize > 0) {
$diskMax = $totalDiskSize;
}
}
} catch (\Throwable $e) {
// If config fetch fails, continue with status value
}
$statistic = ServerStatistics::create([
'server_id' => $this->server->id,
'netin' => $status['netin'] ?? 0,
'netout' => $status['netout'] ?? 0,
'diskread' => $diskRead,
'diskwrite' => $diskWrite,
'diskused' => $diskUsed,
'diskmax' => $diskMax,
'memoryused' => $status['mem'] ?? 0,
'memorymax' => $status['maxmem'] ?? 0,
'cpuused' => $status['cpu'] ?? 0,
'cpumax' => 1.0, // CPU max is typically 1.0 (100%)
'uptime' => $status['uptime'] ?? 0,
'vmstatus' => $status['status'] ?? null,
'DateTime' => now(),
'is_automatic' => false, // Manual statistics are not automatic
]);
// Auto-select the newly created statistic
$this->selectedStatisticId = $statistic->id;
$this->dispatch('statistic-selected');
$this->notify('Statistics created successfully', 'success');
} else {
$this->notify('Failed to retrieve server status from Proxmox', 'error');
}
} catch (\Throwable $e) {
$this->notify('Error creating statistics: ' . $e->getMessage(), 'error');
}
}
public function getSelectedStatisticProperty()
{
if (!$this->selectedStatisticId) {
return null;
}
return ServerStatistics::where('id', $this->selectedStatisticId)
->where('server_id', $this->server->id)
->first();
}
public function getStatisticsProperty()
{
return ServerStatistics::where('server_id', $this->server->id)
->orderBy('DateTime', 'desc')
->paginate(50);
}
public function confirmDeleteStatistic()
{
if (!$this->statisticToDeleteId) {
return;
}
try {
$statistic = ServerStatistics::where('id', $this->statisticToDeleteId)
->where('server_id', $this->server->id)
->first();
if (!$statistic) {
$this->notify('Statistics entry not found', 'error');
$this->closeDeleteModal();
return;
}
// Clear selection if this is the selected statistic
if ($this->selectedStatisticId == $this->statisticToDeleteId) {
$this->selectedStatisticId = null;
}
$statistic->delete();
$this->notify('Statistics entry deleted successfully', 'success');
$this->closeDeleteModal();
} catch (\Throwable $e) {
$this->notify('Error deleting statistics: ' . $e->getMessage(), 'error');
$this->closeDeleteModal();
}
}
public function openClearStatisticsModal(): void
{
$this->showClearStatisticsModal = true;
}
public function closeClearStatisticsModal(): void
{
$this->showClearStatisticsModal = false;
}
public function clearStatisticsHistory()
{
try {
ServerStatistics::where('server_id', $this->server->id)->delete();
$this->selectedStatisticId = null;
$this->statisticToDeleteId = null;
$this->showDeleteModal = false;
$this->showClearStatisticsModal = false;
$this->notify('Statistics history cleared successfully', 'success');
} catch (\Throwable $e) {
$this->notify('Error clearing statistics history: ' . $e->getMessage(), 'error');
}
}
public function openClearTaskHistoryModal(): void
{
$this->showClearTaskHistoryModal = true;
}
public function closeClearTaskHistoryModal(): void
{
$this->showClearTaskHistoryModal = false;
}
public function clearTaskHistory()
{
try {
// Clear any cached task history
$cacheKey = 'proxmox_task_history_' . $this->server->id;
Cache::forget($cacheKey);
// Clear expanded task upids
$this->expandedTaskUpids = [];
$this->showClearTaskHistoryModal = false;
$this->notify('Task history cache cleared successfully', 'success');
} catch (\Throwable $e) {
$this->notify('Error clearing task history: ' . $e->getMessage(), 'error');
}
}
public function getStatisticToDeleteProperty()
{
if (!$this->statisticToDeleteId) {
return null;
}
return ServerStatistics::where('id', $this->statisticToDeleteId)
->where('server_id', $this->server->id)
->first();
}
public function getCurrentNetworkStats($statistic)
{
// Get the previous statistic to calculate the difference
$previous = ServerStatistics::where('server_id', $this->server->id)
->where('DateTime', '<', $statistic->DateTime)
->orderBy('DateTime', 'desc')
->first();
if (!$previous) {
// No previous statistic, return current values as 0
return [
'netin' => 0,
'netout' => 0,
'netin_rate' => 0,
'netout_rate' => 0,
];
}
// Calculate the difference
$netinDiff = max(0, $statistic->netin - $previous->netin);
$netoutDiff = max(0, $statistic->netout - $previous->netout);
// Calculate time difference in seconds
$timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime);
if ($timeDiff == 0) {
$timeDiff = 1; // Avoid division by zero
}
// Calculate rate (bytes per second)
$netinRate = $netinDiff / $timeDiff;
$netoutRate = $netoutDiff / $timeDiff;
return [
'netin' => $netinDiff,
'netout' => $netoutDiff,
'netin_rate' => $netinRate,
'netout_rate' => $netoutRate,
];
}
public function getCurrentDiskStats($statistic)
{
// Get the previous statistic to calculate the difference
$previous = ServerStatistics::where('server_id', $this->server->id)
->where('DateTime', '<', $statistic->DateTime)
->orderBy('DateTime', 'desc')
->first();
if (!$previous) {
// No previous statistic, return current values as 0
return [
'diskread' => 0,
'diskwrite' => 0,
'diskread_rate' => 0,
'diskwrite_rate' => 0,
];
}
// Calculate the difference (handle null values)
$currentDiskread = $statistic->diskread ?? 0;
$previousDiskread = $previous->diskread ?? 0;
$currentDiskwrite = $statistic->diskwrite ?? 0;
$previousDiskwrite = $previous->diskwrite ?? 0;
$diskreadDiff = max(0, $currentDiskread - $previousDiskread);
$diskwriteDiff = max(0, $currentDiskwrite - $previousDiskwrite);
// Calculate time difference in seconds
$timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime);
if ($timeDiff == 0) {
$timeDiff = 1; // Avoid division by zero
}
// Calculate rate (bytes per second)
$diskreadRate = $diskreadDiff / $timeDiff;
$diskwriteRate = $diskwriteDiff / $timeDiff;
return [
'diskread' => $diskreadDiff,
'diskwrite' => $diskwriteDiff,
'diskread_rate' => $diskreadRate,
'diskwrite_rate' => $diskwriteRate,
];
}
public function formatUptime($seconds)
{
if ($seconds < 60) {
return $seconds . 's';
} elseif ($seconds < 3600) {
return floor($seconds / 60) . 'm ' . ($seconds % 60) . 's';
} elseif ($seconds < 86400) {
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
return $hours . 'h ' . $minutes . 'm';
} else {
$days = floor($seconds / 86400);
$hours = floor(($seconds % 86400) / 3600);
return $days . 'd ' . $hours . 'h';
}
}
public function getLatestStatProperty()
{
return ServerStatistics::where('server_id', $this->server->id)
->orderBy('DateTime', 'desc')
->first();
}
public function getTaskHistoryProperty()
{
try {
$proxmox = new Proxmox;
$vmId = (string)$this->server->vm_id;
$allTasks = [];
// Try cluster/tasks endpoint first (includes all tasks across nodes)
// Note: cluster/tasks doesn't support vmid filter, so we'll filter manually
try {
$clusterResponse = $proxmox->request('/cluster/tasks', 'get', []);
$clusterTasks = $clusterResponse->json()['data'] ?? [];
if (is_array($clusterTasks)) {
$allTasks = array_merge($allTasks, $clusterTasks);
}
} catch (\Throwable $e) {
// Cluster endpoint might not be available, continue with node endpoint
\Log::debug('Cluster tasks endpoint failed: ' . $e->getMessage());
}
// Also try node-specific tasks endpoint with vmid filter
try {
$nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', [
'vmid' => (int)$vmId, // Filter by VM ID
]);
$nodeTasks = $nodeResponse->json()['data'] ?? [];
if (is_array($nodeTasks)) {
$allTasks = array_merge($allTasks, $nodeTasks);
}
} catch (\Throwable $e) {
// Node endpoint might fail, try without vmid filter as fallback
try {
$nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', []);
$nodeTasks = $nodeResponse->json()['data'] ?? [];
if (is_array($nodeTasks)) {
$allTasks = array_merge($allTasks, $nodeTasks);
}
} catch (\Throwable $e2) {
\Log::debug('Node tasks endpoint failed: ' . $e2->getMessage());
}
}
// Remove duplicates based on UPID
$uniqueTasks = [];
$seenUpids = [];
foreach ($allTasks as $task) {
$upid = $task['upid'] ?? $task['id'] ?? null;
if ($upid && !in_array($upid, $seenUpids)) {
$seenUpids[] = $upid;
$uniqueTasks[] = $task;
} elseif (!$upid) {
// If no UPID, include it anyway
$uniqueTasks[] = $task;
}
}
$allTasks = $uniqueTasks;
// If no tasks, return empty
if (empty($allTasks)) {
return [];
}
// Filter tasks for this VM
// UPID format: UPID:node:type:pid:user:starttime:extratype:vmid
// The VMID is the last component after the last colon
$tasks = [];
foreach ($allTasks as $task) {
$isVmTask = false;
// Check if task has vmid field directly (as integer or string)
if (isset($task['vmid'])) {
$taskVmid = (string)$task['vmid'];
if ($taskVmid === $vmId) {
$isVmTask = true;
}
}
// Check UPID format - VMID is the last component
// UPID format: UPID:node:type:pid:user:starttime:extratype:vmid
if (!$isVmTask && isset($task['upid']) && is_string($task['upid'])) {
$upid = $task['upid'];
// Extract VMID from UPID - it's the last part after the last colon
$lastColonPos = strrpos($upid, ':');
if ($lastColonPos !== false) {
$taskVmid = substr($upid, $lastColonPos + 1);
if ($taskVmid === $vmId) {
$isVmTask = true;
}
}
// Also try parsing by splitting (in case format is different)
if (!$isVmTask) {
$parts = explode(':', $upid);
if (count($parts) >= 8) {
// Last part (index count-1) is the vmid
$taskVmid = end($parts);
if ($taskVmid === $vmId) {
$isVmTask = true;
}
}
}
}
// Check if task id contains the vmid
if (!$isVmTask && isset($task['id'])) {
$taskId = (string)$task['id'];
// Check if ID ends with the VMID or contains it
if (str_ends_with($taskId, ':' . $vmId) || str_contains($taskId, ':' . $vmId . ':') || $taskId === $vmId) {
$isVmTask = true;
}
}
// Check if node matches and type is qemu (VM-related task)
if (!$isVmTask && isset($task['node']) && $task['node'] === $this->server->node->name) {
$taskType = strtolower($task['type'] ?? '');
// QEMU tasks are VM-related
if ($taskType === 'qemu' || $taskType === 'qm') {
// For qemu tasks, check UPID for vmid
if (isset($task['upid']) && is_string($task['upid'])) {
$lastColonPos = strrpos($task['upid'], ':');
if ($lastColonPos !== false) {
$taskVmid = substr($task['upid'], $lastColonPos + 1);
if ($taskVmid === $vmId) {
$isVmTask = true;
}
}
}
}
}
if ($isVmTask) {
$tasks[] = $task;
}
}
// Sort by starttime descending (newest first)
usort($tasks, function($a, $b) {
$timeA = $a['starttime'] ?? 0;
$timeB = $b['starttime'] ?? 0;
return $timeB <=> $timeA;
});
// Log for debugging if no tasks found
if (empty($tasks)) {
if (!empty($allTasks)) {
\Log::warning('Task history: Found ' . count($allTasks) . ' total tasks, but none matched VM ID ' . $vmId . ' for server ' . $this->server->id, [
'vm_id' => $vmId,
'node' => $this->server->node->name ?? 'N/A',
'sample_tasks' => array_map(function($task) {
return [
'type' => $task['type'] ?? 'N/A',
'vmid' => $task['vmid'] ?? 'N/A',
'upid' => $task['upid'] ?? 'N/A',
'node' => $task['node'] ?? 'N/A',
];
}, array_slice($allTasks, 0, 5)),
]);
} else {
\Log::info('Task history: No tasks found from API for server ' . $this->server->id . ' (VM ID: ' . $vmId . ')');
}
} else {
\Log::debug('Task history: Found ' . count($tasks) . ' tasks for VM ID ' . $vmId);
}
// Limit to last 100 tasks
return array_slice($tasks, 0, 100);
} catch (\Throwable $e) {
// Log error for debugging but return empty array
\Log::error('Error fetching task history for server ' . $this->server->id . ': ' . $e->getMessage());
return [];
}
}
public function getTaskDetails($taskId)
{
try {
$proxmox = new Proxmox;
// Use the /log endpoint to get task log details
$logResponse = $proxmox->request(
'/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/log'
);
$logData = $logResponse->json()['data'] ?? null;
// Also get status for additional information
$statusData = null;
try {
$statusResponse = $proxmox->request(
'/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/status'
);
$statusData = $statusResponse->json()['data'] ?? null;
} catch (\Throwable $e) {
// Status endpoint might fail, continue with log data only
}
// Combine log and status data
$taskDetails = [];
if ($logData) {
$taskDetails['log'] = $logData;
}
if ($statusData) {
$taskDetails = array_merge($taskDetails, $statusData);
}
if (!empty($taskDetails)) {
// Sanitize the task details
$taskDetails = $this->sanitizeTaskData($taskDetails);
}
return $taskDetails;
} catch (\Throwable $e) {
\Log::error('Error fetching task details: ' . $e->getMessage());
return null;
}
}
public function sanitizeTaskData($data)
{
if (is_array($data)) {
$sanitized = [];
foreach ($data as $key => $value) {
$keyLower = strtolower($key);
// Format log output to be human readable
if ($keyLower === 'log' && is_array($value)) {
$sanitized[$key] = $this->formatTaskLog($value);
continue;
}
// Sanitize username fields
if (in_array($keyLower, ['user', 'username'])) {
$sanitized[$key] = '[REDACTED]';
}
// Sanitize VMID if it matches this server's VMID
elseif ($keyLower === 'vmid' && $value == $this->server->vm_id) {
$sanitized[$key] = '[REDACTED]';
}
// Recursively sanitize nested arrays
elseif (is_array($value)) {
$sanitized[$key] = $this->sanitizeTaskData($value);
}
// Sanitize VMID in string paths
elseif (is_string($value) && preg_match('/\/qemu\/(\d+)/', $value)) {
$sanitized[$key] = preg_replace('/\/qemu\/\d+/', '/qemu/[REDACTED]', $value);
}
// Sanitize VMID in UPID format (UPID:node:type:pid:user:starttime:extratype:vmid)
elseif (is_string($value) && preg_match('/^UPID:([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):(\d+)$/', $value, $matches)) {
$sanitized[$key] = preg_replace('/:(\d+)$/', ':[REDACTED]', $value);
}
else {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
return $data;
}
protected function formatTaskLog(array $entries): string
{
$lines = [];
foreach ($entries as $entry) {
if (is_array($entry)) {
$text = isset($entry['t']) ? trim((string) $entry['t']) : trim(json_encode($entry));
if ($text === '') {
$text = '[no output]';
}
$lines[] = $text;
} else {
$lines[] = trim((string) $entry);
}
}
return implode(PHP_EOL, $lines);
}
public function getChartDataProperty()
{
// Get last 50 statistics for chart display
$stats = ServerStatistics::where('server_id', $this->server->id)
->orderBy('DateTime', 'asc')
->limit(50)
->get();
$data = [
'dates' => [],
'cpu' => [],
'memory' => [],
'disk' => [],
'networkIn' => [],
'networkOut' => [],
];
$previousStat = null;
foreach ($stats as $stat) {
$data['dates'][] = $stat->DateTime->format('M d H:i');
$data['cpu'][] = round($stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0, 2);
$data['memory'][] = round($stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0, 2);
$data['disk'][] = round(($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0, 2);
// Calculate network rates
if ($previousStat) {
$netinDiff = max(0, $stat->netin - $previousStat->netin);
$netoutDiff = max(0, $stat->netout - $previousStat->netout);
$timeDiff = $stat->DateTime->diffInSeconds($previousStat->DateTime);
if ($timeDiff == 0) $timeDiff = 1;
$data['networkIn'][] = round($netinDiff / $timeDiff, 2);
$data['networkOut'][] = round($netoutDiff / $timeDiff, 2);
} else {
$data['networkIn'][] = 0;
$data['networkOut'][] = 0;
}
$previousStat = $stat;
}
return $data;
}
public function expandTask($upid)
{
if (!in_array($upid, $this->expandedTaskUpids)) {
$this->expandedTaskUpids[] = $upid;
}
}
public function collapseTask($upid)
{
$this->expandedTaskUpids = array_values(array_filter($this->expandedTaskUpids, function($id) use ($upid) {
return $id !== $upid;
}));
}
public function getHistoricalComparisonProperty()
{
try {
// Get current period statistics
$currentStats = $this->getStatisticsForPeriod($this->currentPeriod, $this->currentCustomDate);
// Get historical period statistics
$historicalStats = $this->getStatisticsForPeriod($this->historicalPeriod, $this->historicalCustomDate);
if (!$currentStats || !$historicalStats) {
return null;
}
// Calculate differences and percentages
$comparison = [
'current' => $currentStats,
'historical' => $historicalStats,
'differences' => [
'cpu' => $this->calculateDifference($currentStats->cpuused, $currentStats->cpumax, $historicalStats->cpuused, $historicalStats->cpumax),
'memory' => $this->calculateDifference($currentStats->memoryused, $currentStats->memorymax, $historicalStats->memoryused, $historicalStats->memorymax),
'disk' => $this->calculateDifference($currentStats->diskused ?? 0, $currentStats->diskmax ?? 0, $historicalStats->diskused ?? 0, $historicalStats->diskmax ?? 0),
'network_in' => $this->calculateRateDifference($currentStats, $historicalStats, 'netin'),
'network_out' => $this->calculateRateDifference($currentStats, $historicalStats, 'netout'),
'disk_read' => $this->calculateRateDifference($currentStats, $historicalStats, 'diskread'),
'disk_write' => $this->calculateRateDifference($currentStats, $historicalStats, 'diskwrite'),
],
];
return $comparison;
} catch (\Throwable $e) {
\Log::error('Error getting historical comparison: ' . $e->getMessage());
return null;
}
}
protected function getStatisticsForPeriod(string $period, ?string $customDate = null)
{
$now = now();
$targetTime = null;
switch ($period) {
case 'now':
// Get the most recent statistic
return ServerStatistics::where('server_id', $this->server->id)
->orderBy('DateTime', 'desc')
->first();
case '1_hour_ago':
$targetTime = $now->copy()->subHour();
break;
case '2_hours_ago':
$targetTime = $now->copy()->subHours(2);
break;
case '6_hours_ago':
$targetTime = $now->copy()->subHours(6);
break;
case '12_hours_ago':
$targetTime = $now->copy()->subHours(12);
break;
case '24_hours_ago':
$targetTime = $now->copy()->subDay();
break;
case 'custom':
if ($customDate) {
try {
$targetTime = \Carbon\Carbon::parse($customDate);
} catch (\Exception $e) {
return null;
}
} else {
return null;
}
break;
default:
return null;
}
if ($targetTime) {
// Find the statistic closest to the target time (within 30 minutes)
return ServerStatistics::where('server_id', $this->server->id)
->whereBetween('DateTime', [
$targetTime->copy()->subMinutes(30),
$targetTime->copy()->addMinutes(30)
])
->orderByRaw('ABS(TIMESTAMPDIFF(MINUTE, DateTime, ?))', [$targetTime])
->first();
}
return null;
}
protected function calculateDifference($currentValue, $currentMax, $historicalValue, $historicalMax)
{
$currentPercent = $currentMax > 0 ? ($currentValue / $currentMax * 100) : 0;
$historicalPercent = $historicalMax > 0 ? ($historicalValue / $historicalMax * 100) : 0;
$absoluteDiff = $currentValue - $historicalValue;
$percentDiff = $currentPercent - $historicalPercent;
$percentChange = $historicalPercent > 0 ? (($currentPercent - $historicalPercent) / $historicalPercent * 100) : 0;
return [
'current_value' => $currentValue,
'current_percent' => round($currentPercent, 2),
'historical_value' => $historicalValue,
'historical_percent' => round($historicalPercent, 2),
'absolute_diff' => $absoluteDiff,
'percent_diff' => round($percentDiff, 2),
'percent_change' => round($percentChange, 2),
];
}
protected function calculateRateDifference($currentStat, $historicalStat, $field)
{
// Get previous stats to calculate rates
$currentPrevious = ServerStatistics::where('server_id', $this->server->id)
->where('DateTime', '<', $currentStat->DateTime)
->orderBy('DateTime', 'desc')
->first();
$historicalPrevious = ServerStatistics::where('server_id', $this->server->id)
->where('DateTime', '<', $historicalStat->DateTime)
->orderBy('DateTime', 'desc')
->first();
$currentRate = 0;
$historicalRate = 0;
if ($currentPrevious) {
$timeDiff = $currentStat->DateTime->diffInSeconds($currentPrevious->DateTime);
if ($timeDiff > 0) {
$currentDiff = max(0, ($currentStat->$field ?? 0) - ($currentPrevious->$field ?? 0));
$currentRate = $currentDiff / $timeDiff;
}
}
if ($historicalPrevious) {
$timeDiff = $historicalStat->DateTime->diffInSeconds($historicalPrevious->DateTime);
if ($timeDiff > 0) {
$historicalDiff = max(0, ($historicalStat->$field ?? 0) - ($historicalPrevious->$field ?? 0));
$historicalRate = $historicalDiff / $timeDiff;
}
}
$absoluteDiff = $currentRate - $historicalRate;
$percentChange = $historicalRate > 0 ? (($currentRate - $historicalRate) / $historicalRate * 100) : ($currentRate > 0 ? 100 : 0);
return [
'current_rate' => $currentRate,
'historical_rate' => $historicalRate,
'absolute_diff' => $absoluteDiff,
'percent_change' => round($percentChange, 2),
];
}
public function getHistoricalComparisonExportDataProperty()
{
$comparison = $this->historicalComparison;
if (!$comparison) {
return [];
}
$current = $comparison['current'];
$historical = $comparison['historical'];
$diff = $comparison['differences'];
$historicalDateTime = $historical->DateTime->format('Y-m-d H:i:s');
$currentDateTime = $current->DateTime->format('Y-m-d H:i:s');
return [
[
'metric' => 'CPU Usage',
'historical_datetime' => $historicalDateTime,
'current_datetime' => $currentDateTime,
'current_value' => round($diff['cpu']['current_percent'], 2) . '%',
'historical_value' => round($diff['cpu']['historical_percent'], 2) . '%',
'difference' => round($diff['cpu']['percent_diff'], 2) . '%',
'percent_change' => round($diff['cpu']['percent_change'], 2) . '%',
],
[
'metric' => 'Memory Usage',
'historical_datetime' => $historicalDateTime,
'current_datetime' => $currentDateTime,
'current_value' => round($diff['memory']['current_percent'], 2) . '%',
'historical_value' => round($diff['memory']['historical_percent'], 2) . '%',
'difference' => round($diff['memory']['percent_diff'], 2) . '%',
'percent_change' => round($diff['memory']['percent_change'], 2) . '%',
],
[
'metric' => 'Disk Usage',
'historical_datetime' => $historicalDateTime,
'current_datetime' => $currentDateTime,
'current_value' => round($diff['disk']['current_percent'], 2) . '%',
'historical_value' => round($diff['disk']['historical_percent'], 2) . '%',
'difference' => round($diff['disk']['percent_diff'], 2) . '%',
'percent_change' => round($diff['disk']['percent_change'], 2) . '%',
],
[
'metric' => 'Network In Rate',
'historical_datetime' => $historicalDateTime,
'current_datetime' => $currentDateTime,
'current_value' => $this->formatBytes($diff['network_in']['current_rate']) . '/s',
'historical_value' => $this->formatBytes($diff['network_in']['historical_rate']) . '/s',
'difference' => $this->formatBytes($diff['network_in']['absolute_diff']) . '/s',
'percent_change' => round($diff['network_in']['percent_change'], 2) . '%',
],
[
'metric' => 'Network Out Rate',
'historical_datetime' => $historicalDateTime,
'current_datetime' => $currentDateTime,
'current_value' => $this->formatBytes($diff['network_out']['current_rate']) . '/s',
'historical_value' => $this->formatBytes($diff['network_out']['historical_rate']) . '/s',
'difference' => $this->formatBytes($diff['network_out']['absolute_diff']) . '/s',
'percent_change' => round($diff['network_out']['percent_change'], 2) . '%',
],
[
'metric' => 'Disk Read Rate',
'historical_datetime' => $historicalDateTime,
'current_datetime' => $currentDateTime,
'current_value' => $this->formatBytes($diff['disk_read']['current_rate']) . '/s',
'historical_value' => $this->formatBytes($diff['disk_read']['historical_rate']) . '/s',
'difference' => $this->formatBytes($diff['disk_read']['absolute_diff']) . '/s',
'percent_change' => round($diff['disk_read']['percent_change'], 2) . '%',
],
[
'metric' => 'Disk Write Rate',
'historical_datetime' => $historicalDateTime,
'current_datetime' => $currentDateTime,
'current_value' => $this->formatBytes($diff['disk_write']['current_rate']) . '/s',
'historical_value' => $this->formatBytes($diff['disk_write']['historical_rate']) . '/s',
'difference' => $this->formatBytes($diff['disk_write']['absolute_diff']) . '/s',
'percent_change' => round($diff['disk_write']['percent_change'], 2) . '%',
],
];
}
public function exportHistoricalComparison()
{
$data = $this->historicalComparisonExportData;
if (empty($data)) {
$this->showExportInfo('No historical comparison data available to export. Please ensure comparison data is displayed before exporting.');
return;
}
$csv = 'Metric,Historical Date/Time,Current Date/Time,Historical Value,Current Value,Difference,Percent Change' . PHP_EOL;
foreach ($data as $row) {
$csv .= sprintf(
'"%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL,
str_replace('"', '""', $row['metric'] ?? ''),
str_replace('"', '""', $row['historical_datetime'] ?? ''),
str_replace('"', '""', $row['current_datetime'] ?? ''),
str_replace('"', '""', $row['historical_value'] ?? ''),
str_replace('"', '""', $row['current_value'] ?? ''),
str_replace('"', '""', $row['difference'] ?? ''),
str_replace('"', '""', $row['percent_change'] ?? '')
);
}
$filename = 'historical-comparison-' . date('Y-m-d') . '.csv';
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, $filename, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
public function exportNetworkAnalysis()
{
$connections = $this->networkAnalysis['connections'] ?? [];
if (empty($connections)) {
$this->showExportInfo('No network analysis data available to export. Please refresh the network analysis first.');
return;
}
$csv = 'Direction,Protocol,Source,Source Port,Destination,Destination Port,State,Bytes,Packets,Bytes (Human)' . PHP_EOL;
foreach ($connections as $connection) {
$csv .= sprintf(
'"%s","%s","%s","%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL,
str_replace('"', '""', $connection['direction'] ?? ''),
str_replace('"', '""', $connection['protocol'] ?? ''),
str_replace('"', '""', $connection['source'] ?? ''),
str_replace('"', '""', (string)($connection['source_port'] ?? '')),
str_replace('"', '""', $connection['destination'] ?? ''),
str_replace('"', '""', (string)($connection['destination_port'] ?? '')),
str_replace('"', '""', $connection['state'] ?? ''),
str_replace('"', '""', (string)($connection['bytes'] ?? 0)),
str_replace('"', '""', (string)($connection['packets'] ?? 0)),
str_replace('"', '""', $connection['bytes_human'] ?? '')
);
}
$filename = 'network-analysis-' . date('Y-m-d_His') . '.csv';
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, $filename, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
public function formatBytes($bytes)
{
if ($bytes == 0 || $bytes < 0) return '0 B';
$k = 1024;
$sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = floor(log($bytes) / log($k));
if ($i < 0) $i = 0;
if ($i >= count($sizes)) $i = count($sizes) - 1;
return round($bytes / pow($k, $i), 2) . ' ' . $sizes[$i];
}
public function getGraphData(string $type): array
{
$fullData = $this->graphPayloads[$type] ?? [];
// Filter data to only return essential fields for this chart type to reduce response size
$filteredData = [];
foreach ($fullData as $point) {
$filteredPoint = ['time' => $point['time'] ?? 0];
switch ($type) {
case 'cpu':
$filteredPoint['cpu'] = $point['cpu'] ?? 0;
break;
case 'memory':
$filteredPoint['mem'] = $point['mem'] ?? 0;
$filteredPoint['maxmem'] = $point['maxmem'] ?? 0;
break;
case 'network':
$filteredPoint['netin'] = $point['netin'] ?? 0;
$filteredPoint['netout'] = $point['netout'] ?? 0;
break;
case 'disk':
$filteredPoint['diskread'] = $point['diskread'] ?? 0;
$filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0;
break;
}
$filteredData[] = $filteredPoint;
}
return $filteredData;
}
protected function coreRouterEnabled(): bool
{
$baseUrl = config('corerouter.base_url');
$apiKey = config('corerouter.api_key');
$username = config('corerouter.username');
$password = config('corerouter.password');
// Check if base_url is configured and not empty
$hasBaseUrl = !empty(trim((string) $baseUrl));
// Check if API key is configured (not empty after trimming)
$hasKey = !empty(trim((string) $apiKey));
// Check if basic auth credentials are both configured
$hasBasic = !empty(trim((string) $username)) && !empty(trim((string) $password));
// Configuration is valid if base_url is set and at least one auth method is configured
$configured = $hasBaseUrl && ($hasKey || $hasBasic);
if (!$configured) {
Log::debug('Core Router configuration check', [
'base_url' => $baseUrl ?: '(empty)',
'base_url_configured' => $hasBaseUrl,
'api_key_present' => $hasKey,
'username_present' => !empty(trim((string) $username)),
'password_present' => !empty(trim((string) $password)),
'basic_auth_configured' => $hasBasic,
'configured' => $configured,
]);
}
return $configured;
}
protected function getServerIps(): array
{
if (!empty($this->cachedServerIps)) {
return $this->cachedServerIps;
}
$this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']);
$rawValues = collect();
$primaryIpv4Value = $this->server->primaryIpv4?->ip ?? (is_string($this->server->primary_ipv4) ? $this->server->primary_ipv4 : null);
$primaryIpv6Value = $this->server->primaryIpv6?->ip ?? (is_string($this->server->primary_ipv6) ? $this->server->primary_ipv6 : null);
if ($primaryIpv4Value) {
$rawValues->push($primaryIpv4Value);
}
if ($primaryIpv6Value) {
$rawValues->push($primaryIpv6Value);
}
$rawValues = $rawValues->merge($this->server->ipAddresses->pluck('ip'));
$exactIps = [];
$cidrs = [];
foreach ($rawValues->filter() as $value) {
foreach ($this->expandIpValue($value) as $parsed) {
if ($parsed['type'] === 'cidr') {
$cidrs[] = $parsed['value'];
} elseif ($parsed['type'] === 'ip') {
// Normalize IP to lowercase for consistent matching
$normalized = $this->normalizeSingleIp($parsed['value']);
if ($normalized) {
$exactIps[] = $normalized;
// For IPv6 addresses, automatically add the /64 subnet to CIDR list
if (str_contains($normalized, ':')) {
// Extract the /64 network prefix for IPv6
$ipBinary = @inet_pton($normalized);
if ($ipBinary !== false && strlen($ipBinary) === 16) {
// Zero out the last 64 bits (last 8 bytes) to get the /64 network
$networkBinary = substr($ipBinary, 0, 8) . str_repeat("\0", 8);
$networkIp = @inet_ntop($networkBinary);
if ($networkIp !== false) {
$cidrs[] = $networkIp . '/64';
}
}
}
// For IPv4 addresses, CIDR ranges are only added if explicitly stored in database
// (e.g., "45.154.99.28/28" format). No auto-generation to avoid false matches.
}
}
}
}
$this->serverIpCidrs = array_values(array_unique($cidrs));
$this->cachedServerIps = array_values(array_unique($exactIps));
Log::debug('Core Router getServerIps - extracted IPs', [
'raw_values' => $rawValues->all(),
'exact_ips' => $this->cachedServerIps,
'cidrs' => $this->serverIpCidrs,
'primary_ipv4' => $primaryIpv4Value,
'primary_ipv6' => $primaryIpv6Value,
]);
return $this->cachedServerIps;
}
/**
* Check if a connection's source or destination matches server's assigned IPs
* Used for visual filtering in the blade template
*/
public function isConnectionRelevant(string $source, string $destination): bool
{
$serverIps = $this->getServerIps();
if (empty($serverIps)) {
return false;
}
// Normalize for comparison
$sourceNormalized = strtolower(trim($source));
$destinationNormalized = strtolower(trim($destination));
// Check exact matches
foreach ($serverIps as $serverIp) {
$serverIpNormalized = strtolower(trim($serverIp));
if ($sourceNormalized === $serverIpNormalized || $destinationNormalized === $serverIpNormalized) {
return true;
}
}
return false;
}
protected function formatConnections(array $states, array $ips): array
{
// Legacy method - redirect to simple version
return $this->formatConnectionsSimple($states);
}
protected function aggregateTopConnections(array $connections): array
{
if (empty($connections)) {
return [];
}
$grouped = [];
foreach ($connections as $connection) {
$key = $connection['direction'] === 'Outbound'
? $connection['destination'] . ':' . ($connection['destination_port'] ?? '0')
: $connection['source'] . ':' . ($connection['source_port'] ?? '0');
if (!isset($grouped[$key])) {
$grouped[$key] = [
'endpoint' => $connection['direction'] === 'Outbound'
? $connection['destination']
: $connection['source'],
'port' => $connection['direction'] === 'Outbound'
? $connection['destination_port']
: $connection['source_port'],
'bytes' => 0,
'packets' => 0,
'count' => 0,
];
}
$grouped[$key]['bytes'] += $connection['bytes'];
$grouped[$key]['packets'] += $connection['packets'];
$grouped[$key]['count']++;
}
$results = array_map(function ($item) {
$item['bytes_human'] = $this->formatBytes($item['bytes']);
return $item;
}, array_values($grouped));
usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']);
return $results;
}
protected function aggregateTopPorts(array $connections): array
{
if (empty($connections)) {
return [];
}
$grouped = [];
foreach ($connections as $connection) {
$port = $connection['destination_port'] ?? $connection['source_port'] ?? null;
if (!$port) {
continue;
}
if (!isset($grouped[$port])) {
$grouped[$port] = [
'port' => $port,
'bytes' => 0,
'packets' => 0,
'connections' => 0,
];
}
$grouped[$port]['bytes'] += $connection['bytes'];
$grouped[$port]['packets'] += $connection['packets'];
$grouped[$port]['connections']++;
}
$results = array_map(function ($item) {
$item['bytes_human'] = $this->formatBytes($item['bytes']);
return $item;
}, array_values($grouped));
usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']);
return $results;
}
/**
* Get gateways from server's network configuration (network tab)
*/
protected function getServerGateways(): array
{
$this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']);
$gateways = [];
// Get gateways from IP addresses
foreach ($this->server->ipAddresses as $ipAddress) {
if (!empty($ipAddress->gateway)) {
$gatewayIp = trim($ipAddress->gateway);
// Avoid duplicates
if (!isset($gateways[$gatewayIp])) {
$gateways[$gatewayIp] = [
'ip' => $gatewayIp,
'name' => $gatewayIp,
'type' => $ipAddress->type ?? 'ipv4', // ipv4 or ipv6
'associated_ip' => $ipAddress->ip,
];
}
}
}
return array_values($gateways);
}
/**
* Check connections against known malicious IP blocklists
*/
protected function checkSecurityThreats(array $connections): array
{
$warnings = [];
if (empty($connections)) {
Log::debug('Security check: No connections to check');
return [];
}
// Fetch and parse blocklists
$ipv6Blocklist = $this->getIpv6Blocklist();
$ipv4Blocklist = $this->getIpv4Blocklist();
Log::debug('Security check: Blocklists loaded', [
'ipv6_count' => count($ipv6Blocklist),
'ipv4_count' => count($ipv4Blocklist),
'connections_count' => count($connections),
]);
foreach ($connections as $connection) {
$source = $connection['source'] ?? '';
$destination = $connection['destination'] ?? '';
// Check source IP
$sourceThreat = $this->checkIpAgainstBlocklist($source, $ipv4Blocklist, $ipv6Blocklist);
if ($sourceThreat) {
// For source threats (malicious IP attacking us), analyze the destination port (port being targeted)
// Always use destination_port as that's the service port being targeted, not the ephemeral source port
$threatPort = $connection['destination_port'] ?? null;
// If destination_port is null, try to extract from raw destination field
if ($threatPort === null && isset($connection['raw_destination'])) {
$threatPort = $this->extractPortFromField($connection['raw_destination']);
}
// NEVER use source_port for attack analysis - it's just an ephemeral port
// Log for debugging
Log::debug('Security threat detected: Source IP - Port Analysis', [
'ip' => $source,
'threat' => $sourceThreat,
'destination_port' => $connection['destination_port'] ?? 'null',
'source_port' => $connection['source_port'] ?? 'null',
'raw_destination' => $connection['raw_destination'] ?? 'null',
'extracted_threat_port' => $threatPort,
]);
$riskAnalysis = $this->analyzeThreatRisk($source, $threatPort, $connection);
$warnings[] = [
'ip' => $source,
'type' => 'source',
'threat' => $sourceThreat,
'connection' => $connection,
'risk_level' => $riskAnalysis['risk_level'],
'attack_type' => $riskAnalysis['attack_type'],
'risk_description' => $riskAnalysis['risk_description'],
'mitigations' => $riskAnalysis['mitigations'] ?? [],
];
Log::debug('Security threat detected: Source IP', [
'ip' => $source,
'threat' => $sourceThreat,
'risk_level' => $riskAnalysis['risk_level'],
'attack_type' => $riskAnalysis['attack_type'],
'threat_port' => $threatPort,
]);
}
// Check destination IP
$destThreat = $this->checkIpAgainstBlocklist($destination, $ipv4Blocklist, $ipv6Blocklist);
if ($destThreat) {
// For destination threats (we're connecting to malicious IP), analyze the destination port (port on malicious server)
// Always use destination_port as that's the service port on the malicious server
$threatPort = $connection['destination_port'] ?? null;
// If destination_port is null, try to extract from raw destination field
if ($threatPort === null && isset($connection['raw_destination'])) {
$threatPort = $this->extractPortFromField($connection['raw_destination']);
}
// NEVER use source_port for attack analysis - it's just an ephemeral port
$riskAnalysis = $this->analyzeThreatRisk($destination, $threatPort, $connection);
$warnings[] = [
'ip' => $destination,
'type' => 'destination',
'threat' => $destThreat,
'connection' => $connection,
'risk_level' => $riskAnalysis['risk_level'],
'attack_type' => $riskAnalysis['attack_type'],
'risk_description' => $riskAnalysis['risk_description'],
'mitigations' => $riskAnalysis['mitigations'] ?? [],
];
Log::debug('Security threat detected: Destination IP', [
'ip' => $destination,
'threat' => $destThreat,
'risk_level' => $riskAnalysis['risk_level'],
'attack_type' => $riskAnalysis['attack_type'],
]);
}
}
Log::debug('Security check completed', [
'warnings_count' => count($warnings),
]);
// Group similar threats together
$groupedWarnings = $this->groupSimilarThreats($warnings);
Log::debug('Security threats grouped', [
'original_count' => count($warnings),
'grouped_count' => count($groupedWarnings),
]);
return $groupedWarnings;
}
/**
* Group similar security threats by source, destination, ports, and protocol
*/
protected function groupSimilarThreats(array $warnings): array
{
if (empty($warnings)) {
return [];
}
$grouped = [];
foreach ($warnings as $warning) {
$connection = $warning['connection'] ?? [];
$source = $connection['source'] ?? '';
$destination = $connection['destination'] ?? '';
$sourcePort = $connection['source_port'] ?? null;
$destinationPort = $connection['destination_port'] ?? null;
$protocol = $connection['protocol'] ?? 'N/A';
$threatType = $warning['type'] ?? 'source';
$threatIp = $warning['ip'] ?? '';
// Create a unique key based on source IP, destination IP, destination port, protocol, and threat type
// Exclude source port from grouping since it's often ephemeral and varies between connections
$key = sprintf(
'%s|%s|%s|%s|%s|%s',
$threatType,
$threatIp,
$source,
$destination,
$destinationPort ?? 'null',
$protocol
);
if (!isset($grouped[$key])) {
// First occurrence - use this warning as the base
$grouped[$key] = $warning;
$grouped[$key]['quantity'] = 1;
$grouped[$key]['is_repeated'] = false;
} else {
// Duplicate found - increment quantity and mark as repeated
$grouped[$key]['quantity'] = ($grouped[$key]['quantity'] ?? 1) + 1;
$grouped[$key]['is_repeated'] = true;
// Aggregate connection data (sum bytes and packets)
$existingConn = $grouped[$key]['connection'] ?? [];
$newConn = $connection;
$grouped[$key]['connection'] = [
'source' => $existingConn['source'] ?? $source,
'destination' => $existingConn['destination'] ?? $destination,
'source_port' => $existingConn['source_port'] ?? $sourcePort,
'destination_port' => $existingConn['destination_port'] ?? $destinationPort,
'protocol' => $existingConn['protocol'] ?? $protocol,
'bytes' => ($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0),
'packets' => ($existingConn['packets'] ?? 0) + ($newConn['packets'] ?? 0),
'bytes_human' => $this->formatBytes(($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0)),
'state' => $existingConn['state'] ?? $newConn['state'] ?? 'N/A',
'direction' => $existingConn['direction'] ?? $newConn['direction'] ?? 'N/A',
'raw_source' => $existingConn['raw_source'] ?? $newConn['raw_source'] ?? '',
'raw_destination' => $existingConn['raw_destination'] ?? $newConn['raw_destination'] ?? '',
];
}
}
// Convert back to indexed array and sort by quantity (descending), then by risk level
$result = array_values($grouped);
usort($result, function ($a, $b) {
$quantityA = $a['quantity'] ?? 1;
$quantityB = $b['quantity'] ?? 1;
// First sort by quantity (descending)
if ($quantityA !== $quantityB) {
return $quantityB <=> $quantityA;
}
// Then by risk level severity
$severityA = $this->getRiskSeverity($a['risk_level'] ?? 'Medium');
$severityB = $this->getRiskSeverity($b['risk_level'] ?? 'Medium');
return $severityB <=> $severityA;
});
return $result;
}
/**
* Check if an IP matches any entry in the blocklists
*/
protected function checkIpAgainstBlocklist(string $ip, array $ipv4Blocklist, array $ipv6Blocklist): ?string
{
if (empty($ip)) {
return null;
}
$normalizedIp = $this->normalizeSingleIp($ip);
if ($normalizedIp === null) {
Log::debug('Security check: Failed to normalize IP', [
'ip' => $ip,
]);
return null;
}
$isIPv6 = str_contains($normalizedIp, ':');
if ($isIPv6) {
// Check IPv6 blocklist (CIDR ranges)
if (empty($ipv6Blocklist)) {
Log::debug('Security check: IPv6 blocklist is empty', [
'ip' => $normalizedIp,
]);
return null;
}
foreach ($ipv6Blocklist as $cidr) {
if ($this->ipInCidr($normalizedIp, $cidr)) {
Log::debug('Security check: IPv6 match found', [
'ip' => $normalizedIp,
'cidr' => $cidr,
]);
return "IPv6 address matches Spamhaus DROPv6 blocklist: {$cidr}";
}
}
} else {
// Check IPv4 blocklist (exact IPs)
if (empty($ipv4Blocklist)) {
Log::debug('Security check: IPv4 blocklist is empty', [
'ip' => $normalizedIp,
]);
return null;
}
if (in_array($normalizedIp, $ipv4Blocklist, true)) {
Log::debug('Security check: IPv4 match found', [
'ip' => $normalizedIp,
]);
return "IPv4 address matches blocklist.de malicious IP list";
}
}
return null;
}
/**
* Analyze threat risk level and attack type based on port and IP
*/
protected function analyzeThreatRisk(string $ip, ?int $port, array $connection): array
{
$riskLevel = 'Medium';
$attackType = 'Unknown Threat';
$riskDescription = 'Malicious IP detected from blocklist';
// Port-based analysis
if ($port !== null) {
$portAnalysis = $this->analyzePortRisk($port, $connection);
$riskLevel = $portAnalysis['risk_level'];
$attackType = $portAnalysis['attack_type'];
$riskDescription = $portAnalysis['description'];
}
// IP-based risk adjustment
$ipRisk = $this->analyzeIpRisk($ip);
if ($ipRisk['severity'] > $this->getRiskSeverity($riskLevel)) {
$riskLevel = $ipRisk['risk_level'];
if ($attackType === 'Unknown Threat') {
$attackType = $ipRisk['attack_type'];
}
}
// Direction-based risk adjustment
$direction = $connection['direction'] ?? '';
if ($direction === 'Inbound' && $riskLevel !== 'Critical') {
// Inbound threats are generally more dangerous
if ($riskLevel === 'Low') {
$riskLevel = 'Medium';
} elseif ($riskLevel === 'Medium') {
$riskLevel = 'High';
}
}
// Get mitigation suggestions based on attack type and port
$mitigations = $this->getMitigationSuggestions($attackType, $port, $connection);
return [
'risk_level' => $riskLevel,
'attack_type' => $attackType,
'risk_description' => $riskDescription,
'mitigations' => $mitigations,
];
}
/**
* Get mitigation suggestions based on attack type and port
*/
protected function getMitigationSuggestions(string $attackType, ?int $port, array $connection): array
{
$mitigations = [];
$direction = $connection['direction'] ?? '';
$isInbound = $direction === 'Inbound';
// Don't provide mitigations for outbound threats (we're connecting to malicious IPs)
if (!$isInbound) {
return [
[
'priority' => 'High',
'title' => 'Review Outbound Connections',
'description' => 'This server is initiating connections to a malicious IP. Review the application or service making these connections and ensure it is legitimate and up-to-date.',
'action' => 'Review application logs and system processes to identify why connections are being made to this malicious IP address.',
]
];
}
// SSH Brute Force (port 22)
if ($attackType === 'SSH Brute Force' || $port === 22) {
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Implement fail2ban',
'description' => 'Automatically ban IPs that fail SSH authentication multiple times. This is highly effective against brute force attacks.',
'action' => "Install: sudo apt install fail2ban (Ubuntu/Debian) or sudo yum install fail2ban (RHEL/CentOS). Configure /etc/fail2ban/jail.local to enable sshd protection.",
],
[
'priority' => 'Critical',
'title' => 'Disable password authentication, use SSH keys only',
'description' => 'Eliminate the attack vector by removing password-based authentication entirely.',
'action' => 'Edit /etc/ssh/sshd_config: Set PasswordAuthentication no, PubkeyAuthentication yes. Restart SSH: sudo systemctl restart sshd',
],
[
'priority' => 'High',
'title' => 'Change SSH port',
'description' => 'Reduce automated attack attempts by moving SSH to a non-standard port.',
'action' => 'Edit /etc/ssh/sshd_config: Change Port 22 to Port [your chosen port]. Update firewall rules. Restart SSH: sudo systemctl restart sshd',
],
[
'priority' => 'High',
'title' => 'Disable root login',
'description' => 'Prevent direct root login attempts via SSH.',
'action' => 'Edit /etc/ssh/sshd_config: Set PermitRootLogin no. Restart SSH: sudo systemctl restart sshd',
],
[
'priority' => 'Medium',
'title' => 'Configure iptables/UFW rate limiting',
'description' => 'Limit connection attempts per IP address to prevent brute force attacks.',
'action' => 'UFW: sudo ufw limit ssh/tcp. iptables: sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set --name ssh && sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --update --seconds 60 --hitcount 4 --name ssh -j DROP',
],
[
'priority' => 'Medium',
'title' => 'Use TCP Wrappers',
'description' => 'Additional access control layer for SSH connections.',
'action' => 'Edit /etc/hosts.allow and /etc/hosts.deny to restrict SSH access by IP address or hostname.',
],
[
'priority' => 'Medium',
'title' => 'Implement two-factor authentication (2FA)',
'description' => 'Add an extra layer of security even if credentials are compromised.',
'action' => 'Use Google Authenticator or similar: Install libpam-google-authenticator, configure PAM for SSH.',
],
];
}
// RDP Attack (port 3389)
if ($attackType === 'RDP Attack' || $port === 3389) {
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Disable RDP if not needed',
'description' => 'RDP is a high-value target for attackers. If not required, disable it completely.',
'action' => 'Windows: Disable Remote Desktop in System Settings > Remote Desktop. Linux with xrdp: sudo systemctl disable xrdp && sudo systemctl stop xrdp',
],
[
'priority' => 'Critical',
'title' => 'Use VPN instead of exposing RDP',
'description' => 'Never expose RDP directly to the internet. Route connections through a VPN.',
'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to RDP through VPN tunnel only.',
],
[
'priority' => 'High',
'title' => 'Implement fail2ban for RDP',
'description' => 'Automatically ban IPs that fail RDP authentication multiple times.',
'action' => 'Install fail2ban and configure a custom jail for RDP/xrdp. Monitor authentication logs and ban repeated failures.',
],
[
'priority' => 'High',
'title' => 'Change RDP port',
'description' => 'Reduce automated attack attempts by moving RDP to a non-standard port.',
'action' => 'Windows: Edit registry key HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp\\PortNumber. Linux xrdp: Edit /etc/xrdp/xrdp.ini, change port=3389',
],
[
'priority' => 'High',
'title' => 'Enable Network Level Authentication (NLA)',
'description' => 'Require authentication before establishing RDP session.',
'action' => 'Windows: Enable NLA in Remote Desktop settings. This adds an extra authentication layer before the session is established.',
],
[
'priority' => 'Medium',
'title' => 'Configure firewall rules',
'description' => 'Restrict RDP access to specific trusted IP addresses only.',
'action' => 'UFW: sudo ufw allow from [trusted-ip] to any port 3389. iptables: sudo iptables -A INPUT -p tcp -s [trusted-ip] --dport 3389 -j ACCEPT && sudo iptables -A INPUT -p tcp --dport 3389 -j DROP',
],
];
}
// Database attacks (MySQL, PostgreSQL, SQL Server)
if (in_array($attackType, ['Database Attack', 'SQL Injection']) || in_array($port, [1433, 3306, 5432])) {
$dbType = $port === 1433 ? 'SQL Server' : ($port === 3306 ? 'MySQL' : ($port === 5432 ? 'PostgreSQL' : 'Database'));
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Block database port at firewall level',
'description' => "Never expose {$dbType} directly to the internet. Databases should only be accessible from application servers.",
'action' => "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP",
],
[
'priority' => 'Critical',
'title' => 'Use SSH tunnel or VPN for database access',
'description' => 'If remote database access is needed, route it through an encrypted tunnel.',
'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:[db-port] user@server. Connect database client to localhost:[local-port]',
],
[
'priority' => 'High',
'title' => 'Bind database to localhost only',
'description' => "Configure {$dbType} to only listen on localhost interface, not on public interfaces.",
'action' => "MySQL: Edit my.cnf, set bind-address = 127.0.0.1. PostgreSQL: Edit postgresql.conf, set listen_addresses = 'localhost'. Restart database service.",
],
[
'priority' => 'High',
'title' => 'Use strong passwords and limit user privileges',
'description' => 'Implement least-privilege access and strong authentication.',
'action' => "Change default passwords. Create database users with minimal required privileges. Use {$dbType} user management to restrict access by IP.",
],
[
'priority' => 'High',
'title' => 'Implement fail2ban for database',
'description' => 'Automatically ban IPs that fail database authentication multiple times.',
'action' => 'Configure fail2ban to monitor database authentication logs. Create custom jail for repeated authentication failures.',
],
[
'priority' => 'Medium',
'title' => 'Enable SSL/TLS for database connections',
'description' => 'Encrypt database connections to prevent credential theft.',
'action' => "Configure SSL certificates for {$dbType}. Update connection strings to require SSL. Update firewall rules accordingly.",
],
];
}
// SMB/NetBIOS attacks
if (in_array($attackType, ['SMB Exploit', 'NetBIOS Attack']) || in_array($port, [445, 139])) {
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Disable SMB if not needed',
'description' => 'SMB is a common attack vector for ransomware and lateral movement. Disable if not required.',
'action' => 'Linux: sudo systemctl disable smbd nmbd && sudo systemctl stop smbd nmbd. Windows: Disable SMB in Windows Features.',
],
[
'priority' => 'High',
'title' => 'Block SMB ports at firewall',
'description' => 'Prevent SMB access from the internet. It should only be accessible on local network.',
'action' => 'UFW: sudo ufw deny 445/tcp && sudo ufw deny 139/tcp. iptables: Block ports 445 and 139 from external interfaces.',
],
[
'priority' => 'High',
'title' => 'Use VPN for SMB access',
'description' => 'If SMB access is required remotely, route it through a VPN tunnel.',
'action' => 'Set up OpenVPN or WireGuard VPN. Only allow SMB access through VPN tunnel. Configure firewall accordingly.',
],
[
'priority' => 'High',
'title' => 'Update Samba/Windows to latest version',
'description' => 'Older SMB versions have known vulnerabilities. Keep software updated.',
'action' => 'Update Samba: sudo apt update && sudo apt upgrade samba (Ubuntu/Debian). Windows: Install latest security updates.',
],
[
'priority' => 'Medium',
'title' => 'Disable SMBv1 protocol',
'description' => 'SMBv1 is deprecated and vulnerable. Use SMBv2 or SMBv3 only.',
'action' => 'Linux: Edit smb.conf, add min protocol = SMB2. Windows: Disable SMBv1 in Windows Features or via PowerShell: Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol',
],
[
'priority' => 'Medium',
'title' => 'Restrict SMB access by IP',
'description' => 'Limit SMB access to specific trusted IP addresses only.',
'action' => 'Configure firewall rules to only allow SMB connections from trusted IPs. Use hosts.allow for additional access control.',
],
];
}
// Web attacks (HTTP/HTTPS)
if ($attackType === 'Web Attack' || in_array($port, [80, 443, 8080, 8443])) {
$mitigations = [
[
'priority' => 'High',
'title' => 'Implement Web Application Firewall (WAF)',
'description' => 'Protect against web application attacks, SQL injection, XSS, and other web-based exploits.',
'action' => 'Use ModSecurity with Apache/Nginx, Cloudflare WAF, or AWS WAF. Configure rules to block common attack patterns.',
],
[
'priority' => 'High',
'title' => 'Configure rate limiting',
'description' => 'Limit requests per IP to prevent DDoS and brute force attacks.',
'action' => 'Nginx: limit_req_zone and limit_req directives. Apache: mod_evasive or mod_ratelimit. Also configure at firewall level with iptables/UFW.',
],
[
'priority' => 'High',
'title' => 'Keep web server and applications updated',
'description' => 'Outdated software has known vulnerabilities that attackers exploit.',
'action' => 'Regularly update web server (Apache/Nginx), PHP, and all web applications. Subscribe to security advisories.',
],
[
'priority' => 'Medium',
'title' => 'Implement fail2ban for web services',
'description' => 'Automatically ban IPs showing malicious web request patterns.',
'action' => 'Configure fail2ban to monitor web server logs. Create custom filters for repeated 404 errors, SQL injection attempts, etc.',
],
[
'priority' => 'Medium',
'title' => 'Use HTTPS only (port 443)',
'description' => 'Encrypt web traffic to prevent man-in-the-middle attacks and credential theft.',
'action' => 'Install SSL certificate (Let\'s Encrypt). Configure web server to redirect HTTP (80) to HTTPS (443). Enable HSTS headers.',
],
[
'priority' => 'Medium',
'title' => 'Configure firewall to block unnecessary ports',
'description' => 'If using standard HTTPS (443), block alternative ports like 8080 and 8443 unless needed.',
'action' => 'UFW: sudo ufw deny 8080/tcp && sudo ufw deny 8443/tcp (if not needed). Only allow required web ports.',
],
[
'priority' => 'Low',
'title' => 'Hide server version information',
'description' => 'Reduce information disclosure that helps attackers identify vulnerabilities.',
'action' => 'Configure web server to hide version banners. Disable server signature: ServerTokens Prod (Apache), server_tokens off (Nginx).',
],
];
}
// Email attacks
if (str_contains($attackType, 'Email') || in_array($port, [25, 110, 143, 993, 995])) {
$mitigations = [
[
'priority' => 'High',
'title' => 'Configure SPF, DKIM, and DMARC records',
'description' => 'Prevent email spoofing and improve email authentication.',
'action' => 'Add SPF, DKIM, and DMARC DNS records for your domain. Configure mail server to sign outgoing emails.',
],
[
'priority' => 'High',
'title' => 'Require strong authentication',
'description' => 'Use strong passwords and consider implementing two-factor authentication for email.',
'action' => 'Enforce password complexity policies. Enable 2FA if supported. Use OAuth2 or modern authentication methods.',
],
[
'priority' => 'High',
'title' => 'Block port 25 (SMTP) from external access',
'description' => 'Prevent unauthorized email relaying and spam attacks.',
'action' => 'Configure mail server to only accept SMTP connections from trusted IPs. Use UFW/iptables to restrict port 25 access.',
],
[
'priority' => 'Medium',
'title' => 'Implement fail2ban for mail services',
'description' => 'Automatically ban IPs that fail email authentication multiple times.',
'action' => 'Configure fail2ban to monitor mail server authentication logs. Create jails for POP3, IMAP, and SMTP authentication failures.',
],
[
'priority' => 'Medium',
'title' => 'Use encrypted email protocols',
'description' => 'Prefer secure protocols (IMAPS, POP3S) over unencrypted ones.',
'action' => 'Disable unencrypted POP3 (110) and IMAP (143). Require SSL/TLS for all email connections. Update firewall rules.',
],
[
'priority' => 'Low',
'title' => 'Change default email ports',
'description' => 'Reduce automated attack attempts by using non-standard ports.',
'action' => 'Consider changing to non-standard ports for email services if your email clients support it. However, this reduces compatibility.',
],
];
}
// FTP attacks
if ($attackType === 'FTP Attack' || $port === 21) {
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Disable FTP, use SFTP instead',
'description' => 'FTP is unencrypted and insecure. SFTP (over SSH) is the secure alternative.',
'action' => 'Disable FTP server: sudo systemctl disable vsftpd (or proftpd). Use SSH/SFTP instead, which uses port 22 and is encrypted.',
],
[
'priority' => 'High',
'title' => 'If FTP is required, use FTPS only',
'description' => 'Use FTP over SSL/TLS to encrypt credentials and data.',
'action' => 'Configure FTP server to require TLS/SSL. Disable plain FTP. Block port 21 for plain FTP connections.',
],
[
'priority' => 'High',
'title' => 'Restrict FTP access by IP',
'description' => 'Limit FTP access to specific trusted IP addresses only.',
'action' => 'Configure firewall to only allow FTP from trusted IPs. Use FTP server configuration to restrict access by IP address.',
],
[
'priority' => 'Medium',
'title' => 'Implement fail2ban for FTP',
'description' => 'Automatically ban IPs that fail FTP authentication multiple times.',
'action' => 'Configure fail2ban to monitor FTP authentication logs. Create custom jail for repeated authentication failures.',
],
];
}
// Telnet attacks
if ($attackType === 'Telnet Exploit' || $port === 23) {
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Disable Telnet completely',
'description' => 'Telnet is completely unencrypted and should never be used. Use SSH instead.',
'action' => 'Disable Telnet: sudo systemctl disable telnet && sudo systemctl stop telnet. Remove telnet server package. Block port 23 at firewall.',
],
[
'priority' => 'Critical',
'title' => 'Use SSH instead',
'description' => 'SSH provides encrypted remote access, replacing the insecure Telnet protocol.',
'action' => 'Use SSH (port 22) for all remote terminal access. SSH encrypts all data and credentials.',
],
];
}
// VNC attacks
if ($attackType === 'VNC Attack' || $port === 5900) {
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Never expose VNC directly to internet',
'description' => 'VNC is vulnerable to brute force attacks. Always route through VPN or SSH tunnel.',
'action' => 'Block VNC port at firewall. Use SSH tunnel: ssh -L 5900:localhost:5900 user@server. Connect VNC client to localhost:5900',
],
[
'priority' => 'High',
'title' => 'Use SSH tunnel for VNC access',
'description' => 'Encrypt VNC connections by tunneling through SSH.',
'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:5900 user@server. Connect VNC to localhost:[local-port]. Configure firewall to block port 5900.',
],
[
'priority' => 'High',
'title' => 'Enable VNC password and strong authentication',
'description' => 'Use strong passwords if VNC must be used without tunneling.',
'action' => 'Set strong VNC password: vncpasswd. Consider using VNC over SSL if available. Still prefer SSH tunneling.',
],
[
'priority' => 'Medium',
'title' => 'Change VNC port',
'description' => 'Reduce automated attack attempts by using non-standard port.',
'action' => 'Configure VNC to use non-standard port. Update firewall rules. Still route through SSH tunnel for security.',
],
];
}
// DNS attacks
if ($attackType === 'DNS Attack' || $port === 53) {
$mitigations = [
[
'priority' => 'High',
'title' => 'Restrict DNS to local network only',
'description' => 'DNS servers should typically only serve local networks, not be exposed to the internet.',
'action' => 'Configure DNS server to only listen on local network interface. Block external DNS queries at firewall unless you operate a public DNS server.',
],
[
'priority' => 'High',
'title' => 'Use DNS over HTTPS (DoH) or DNS over TLS (DoT)',
'description' => 'Encrypt DNS queries to prevent interception and manipulation.',
'action' => 'Configure DNS clients to use DoH/DoT. Update applications to use encrypted DNS protocols.',
],
[
'priority' => 'Medium',
'title' => 'Configure rate limiting for DNS',
'description' => 'Prevent DNS amplification attacks and excessive queries.',
'action' => 'Configure DNS server (BIND, unbound) with rate limiting. Limit queries per IP address.',
],
[
'priority' => 'Medium',
'title' => 'Keep DNS software updated',
'description' => 'Outdated DNS software has known vulnerabilities.',
'action' => 'Regularly update DNS server software. Subscribe to security advisories for your DNS server (BIND, unbound, etc.).',
],
];
}
// Windows RPC attacks
if ($attackType === 'Windows Exploit' || $port === 135) {
$mitigations = [
[
'priority' => 'Critical',
'title' => 'Block Windows RPC ports at firewall',
'description' => 'Windows RPC should never be exposed to the internet. It is used for service enumeration and attacks.',
'action' => 'Block ports 135, 137, 138, 139, 445 at firewall. Windows: Configure Windows Firewall. Linux firewall: Use UFW/iptables to block these ports.',
],
[
'priority' => 'High',
'title' => 'Use VPN for Windows RPC access',
'description' => 'If Windows RPC access is needed remotely, route it through a VPN tunnel.',
'action' => 'Set up VPN server. Only allow Windows RPC access through VPN tunnel. Configure firewall to block direct RPC access.',
],
[
'priority' => 'High',
'title' => 'Keep Windows updated',
'description' => 'Windows RPC vulnerabilities are regularly patched. Keep system updated.',
'action' => 'Install latest Windows security updates. Enable automatic updates for critical security patches.',
],
];
}
// Generic/unknown attacks or port scans
if (empty($mitigations)) {
$mitigations = [
[
'priority' => 'High',
'title' => 'Block port at firewall',
'description' => "If this port ({$port}) is not needed, block it completely at the firewall level.",
'action' => $port ? "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP" : 'Configure firewall to block this port.',
],
[
'priority' => 'High',
'title' => 'Implement fail2ban',
'description' => 'Automatically ban IPs showing malicious connection patterns.',
'action' => 'Install fail2ban. Create custom filter to monitor logs for this service. Configure jail to ban repeated connection attempts.',
],
[
'priority' => 'Medium',
'title' => 'Review service necessity',
'description' => "Evaluate if the service on port {$port} is necessary. Disable unused services to reduce attack surface.",
'action' => 'Review running services: sudo netstat -tulpn or sudo ss -tulpn. Disable services that are not required.',
],
[
'priority' => 'Medium',
'title' => 'Restrict access by IP address',
'description' => 'Limit access to specific trusted IP addresses only.',
'action' => $port ? "UFW: sudo ufw allow from [trusted-ip] to any port {$port}. iptables: Allow only from trusted IPs." : 'Configure firewall to restrict access by IP.',
],
[
'priority' => 'Low',
'title' => 'Use VPN for remote access',
'description' => 'If remote access is needed, route connections through a VPN instead of exposing services directly.',
'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to services through VPN tunnel.',
],
];
}
// Add general mitigations for all attacks
$generalMitigations = [
[
'priority' => 'Medium',
'title' => 'Implement IP blocklist at firewall',
'description' => 'Automatically block known malicious IP addresses at the firewall level.',
'action' => 'Use ipset with iptables to maintain blocklists. Integrate with threat intelligence feeds. Automatically update blocklists.',
],
[
'priority' => 'Low',
'title' => 'Monitor and log all connection attempts',
'description' => 'Maintain comprehensive logs for security analysis and incident response.',
'action' => 'Configure syslog to centralize logs. Use log analysis tools (ELK stack, Splunk). Set up alerts for suspicious activity patterns.',
],
];
// Merge general mitigations
$mitigations = array_merge($mitigations, $generalMitigations);
// Sort by priority (Critical > High > Medium > Low)
$priorityOrder = ['Critical' => 4, 'High' => 3, 'Medium' => 2, 'Low' => 1];
usort($mitigations, function ($a, $b) use ($priorityOrder) {
$priA = $priorityOrder[$a['priority']] ?? 0;
$priB = $priorityOrder[$b['priority']] ?? 0;
return $priB <=> $priA;
});
return $mitigations;
}
/**
* Analyze risk based on port number
*/
protected function analyzePortRisk(int $port, array $connection): array
{
// Critical risk ports (commonly exploited)
$criticalPorts = [
22 => ['attack_type' => 'SSH Brute Force', 'description' => 'SSH (port 22) is a common target for brute force attacks attempting to gain unauthorized access'],
23 => ['attack_type' => 'Telnet Exploit', 'description' => 'Telnet (port 23) is unencrypted and highly vulnerable to credential theft and man-in-the-middle attacks'],
3389 => ['attack_type' => 'RDP Attack', 'description' => 'RDP (port 3389) is frequently targeted for remote desktop brute force and ransomware attacks'],
445 => ['attack_type' => 'SMB Exploit', 'description' => 'SMB (port 445) is commonly exploited for ransomware, lateral movement, and data exfiltration'],
1433 => ['attack_type' => 'SQL Injection', 'description' => 'SQL Server (port 1433) is targeted for SQL injection attacks and database compromise'],
3306 => ['attack_type' => 'Database Attack', 'description' => 'MySQL (port 3306) is targeted for database brute force and SQL injection attacks'],
5432 => ['attack_type' => 'Database Attack', 'description' => 'PostgreSQL (port 5432) is targeted for database exploitation and unauthorized access'],
135 => ['attack_type' => 'Windows Exploit', 'description' => 'Windows RPC (port 135) is used for service enumeration and remote procedure call attacks'],
139 => ['attack_type' => 'NetBIOS Attack', 'description' => 'NetBIOS (port 139) is exploited for network enumeration and SMB protocol attacks'],
];
// High risk ports
$highRiskPorts = [
21 => ['attack_type' => 'FTP Attack', 'description' => 'FTP (port 21) is vulnerable to credential theft and unencrypted data transmission'],
25 => ['attack_type' => 'Email Spam', 'description' => 'SMTP (port 25) is commonly used for spam, phishing, and email-based attacks'],
53 => ['attack_type' => 'DNS Attack', 'description' => 'DNS (port 53) can be exploited for DNS amplification, cache poisoning, or data exfiltration'],
110 => ['attack_type' => 'Email Attack', 'description' => 'POP3 (port 110) is vulnerable to credential theft and email interception'],
143 => ['attack_type' => 'Email Attack', 'description' => 'IMAP (port 143) is vulnerable to credential theft and email interception'],
993 => ['attack_type' => 'Email Attack', 'description' => 'IMAPS (port 993) can be targeted for email account compromise'],
995 => ['attack_type' => 'Email Attack', 'description' => 'POP3S (port 995) can be targeted for email account compromise'],
8080 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTP (port 8080) is often targeted for web application attacks and exploits'],
8443 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTPS (port 8443) can be targeted for web application vulnerabilities'],
5900 => ['attack_type' => 'VNC Attack', 'description' => 'VNC (port 5900) is vulnerable to brute force attacks and unauthorized remote access'],
];
// Medium risk ports
$mediumRiskPorts = [
80 => ['attack_type' => 'Web Attack', 'description' => 'HTTP (port 80) can be targeted for web application vulnerabilities and DDoS attacks'],
443 => ['attack_type' => 'Web Attack', 'description' => 'HTTPS (port 443) can be targeted for SSL/TLS vulnerabilities and web application attacks'],
161 => ['attack_type' => 'Network Scan', 'description' => 'SNMP (port 161) is used for network monitoring but can be exploited for information gathering'],
514 => ['attack_type' => 'Log Injection', 'description' => 'Syslog (port 514) can be targeted for log injection and denial of service attacks'],
1521 => ['attack_type' => 'Database Attack', 'description' => 'Oracle (port 1521) can be targeted for database exploitation'],
27017 => ['attack_type' => 'Database Attack', 'description' => 'MongoDB (port 27017) can be targeted for database compromise if misconfigured'],
];
// Check critical ports
if (isset($criticalPorts[$port])) {
return [
'risk_level' => 'Critical',
'attack_type' => $criticalPorts[$port]['attack_type'],
'description' => $criticalPorts[$port]['description'],
];
}
// Check high risk ports
if (isset($highRiskPorts[$port])) {
return [
'risk_level' => 'High',
'attack_type' => $highRiskPorts[$port]['attack_type'],
'description' => $highRiskPorts[$port]['description'],
];
}
// Check medium risk ports
if (isset($mediumRiskPorts[$port])) {
return [
'risk_level' => 'Medium',
'attack_type' => $mediumRiskPorts[$port]['attack_type'],
'description' => $mediumRiskPorts[$port]['description'],
];
}
// Low ports (< 1024) are generally more risky
if ($port < 1024) {
return [
'risk_level' => 'Medium',
'attack_type' => 'System Service Exploit',
'description' => "Low port number ({$port}) indicates a system service that could be targeted for exploitation",
];
}
// High ports (> 49152) are often used for attacks
if ($port > 49152) {
return [
'risk_level' => 'Low',
'attack_type' => 'Ephemeral Port Scan',
'description' => "High port number ({$port}) suggests ephemeral port usage, possibly part of a port scan or connection attempt",
];
}
// Default for other ports
return [
'risk_level' => 'Low',
'attack_type' => 'Port Scan',
'description' => "Connection attempt targeting port {$port} (destination service port) from a malicious IP address",
];
}
/**
* Analyze risk based on IP characteristics
*/
protected function analyzeIpRisk(string $ip): array
{
// Check if IP is in private range (less risky if internal)
if (!$this->isPublicIP($ip)) {
return [
'risk_level' => 'Low',
'attack_type' => 'Internal Threat',
'severity' => 1,
'description' => 'Malicious IP is in private/internal range',
];
}
// IPv6 addresses from blocklists are generally high risk
if (str_contains($ip, ':')) {
return [
'risk_level' => 'High',
'attack_type' => 'IPv6 Blocklist Match',
'severity' => 4,
'description' => 'IPv6 address matches Spamhaus DROPv6 blocklist (known malicious network)',
];
}
// IPv4 addresses from blocklists are high risk
return [
'risk_level' => 'High',
'attack_type' => 'IPv4 Blocklist Match',
'severity' => 4,
'description' => 'IPv4 address matches blocklist.de malicious IP list',
];
}
/**
* Get numeric severity for risk level comparison
*/
protected function getRiskSeverity(string $riskLevel): int
{
return match($riskLevel) {
'Critical' => 5,
'High' => 4,
'Medium' => 3,
'Low' => 2,
default => 1,
};
}
/**
* Fetch and cache IPv6 blocklist from Spamhaus DROPv6
*/
protected function getIpv6Blocklist(): array
{
return Cache::remember('spamhaus-dropv6-blocklist', 3600, function () {
try {
$response = Http::timeout(10)->get('https://www.spamhaus.org/drop/dropv6.txt');
if (!$response->successful()) {
Log::warning('Failed to fetch Spamhaus DROPv6 blocklist', [
'status' => $response->status(),
]);
return [];
}
$lines = explode("\n", $response->body());
$cidrs = [];
foreach ($lines as $line) {
$line = trim($line);
// Skip comments and empty lines
if (empty($line) || str_starts_with($line, ';') || str_starts_with($line, '#')) {
continue;
}
// Extract CIDR (everything before ;)
$parts = explode(';', $line, 2);
$cidr = trim($parts[0]);
if (!empty($cidr)) {
// Normalize CIDR if needed
if (str_contains($cidr, '/')) {
[$ip, $prefix] = explode('/', $cidr, 2);
$normalizedIp = $this->normalizeSingleIp($ip);
if ($normalizedIp !== null) {
$normalizedCidr = $this->normalizeCidr($normalizedIp, (int)$prefix);
if ($normalizedCidr) {
$cidrs[] = $normalizedCidr;
}
}
}
}
}
Log::debug('Fetched Spamhaus DROPv6 blocklist', [
'cidr_count' => count($cidrs),
'sample_cidrs' => array_slice($cidrs, 0, 5),
]);
return array_unique($cidrs);
} catch (\Throwable $e) {
Log::error('Error fetching Spamhaus DROPv6 blocklist', [
'error' => $e->getMessage(),
]);
return [];
}
});
}
/**
* Fetch and cache IPv4 blocklist from blocklist.de
*/
protected function getIpv4Blocklist(): array
{
return Cache::remember('blocklist-de-ipv4-blocklist', 3600, function () {
try {
$response = Http::timeout(10)->get('https://lists.blocklist.de/lists/all.txt');
if (!$response->successful()) {
Log::warning('Failed to fetch blocklist.de IPv4 blocklist', [
'status' => $response->status(),
]);
return [];
}
$lines = explode("\n", $response->body());
$ips = [];
foreach ($lines as $line) {
$line = trim($line);
// Skip empty lines
if (empty($line)) {
continue;
}
// Validate and normalize IP
$normalizedIp = $this->normalizeSingleIp($line);
if ($normalizedIp !== null && !str_contains($normalizedIp, ':')) {
// Only IPv4 addresses
$ips[] = $normalizedIp;
}
}
Log::debug('Fetched blocklist.de IPv4 blocklist', [
'ip_count' => count($ips),
'sample_ips' => array_slice($ips, 0, 5),
]);
return array_unique($ips);
} catch (\Throwable $e) {
Log::error('Error fetching blocklist.de IPv4 blocklist', [
'error' => $e->getMessage(),
]);
return [];
}
});
}
protected function extractStateIp(array $state, string $direction): string
{
// Core Router API returns source/destination in format: "IP[port]" or separate fields
$candidates = [
$direction === 'src' ? 'source' : 'destination',
"{$direction}_ip",
"{$direction}ip",
$direction,
$direction === 'src' ? 'source_ip' : 'destination_ip',
];
foreach ($candidates as $candidate) {
if (!empty($state[$candidate])) {
$value = (string) $state[$candidate];
// Parse format: "2406:da12:fc3:5300::11[53443]" or "45.154.99.28[22]" -> extract IP part
// Also handle IPv6 format: "2a0b:4e07:8:1::1:7[12844]"
if (preg_match('/^([^\[\]]+)(\[.*\])?$/', $value, $matches)) {
$ip = trim($matches[1]);
// Normalize the extracted IP
$normalized = $this->normalizeSingleIp($ip);
return $normalized ?: $ip;
}
// Handle IPv4 in "ip:port" format (ensure IPv6 not affected)
$colonPos = strrpos($value, ':');
if ($colonPos !== false) {
$ipPart = substr($value, 0, $colonPos);
$portPart = substr($value, $colonPos + 1);
// Only treat as IPv4 if the portion before colon is a valid IPv4 address
if (filter_var($ipPart, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && ctype_digit($portPart)) {
$normalized = $this->normalizeSingleIp($ipPart);
return $normalized ?: $ipPart;
}
}
// If no bracket format, try to normalize anyway
$normalized = $this->normalizeSingleIp($value);
return $normalized ?: $value;
}
}
return 'N/A';
}
protected function extractStatePort(array $state, string $direction): ?int
{
// Core Router API returns source/destination in format: "IP[port]" or separate fields
$candidates = [
$direction === 'src' ? 'source' : 'destination',
"{$direction}_port",
"{$direction}port",
$direction === 'src' ? 'source_port' : 'destination_port',
$direction === 'src' ? 'sport' : 'dport',
];
foreach ($candidates as $candidate) {
if (isset($state[$candidate])) {
$value = $state[$candidate];
// If it's a string in format "IP[port]", extract port
if (is_string($value) && preg_match('/\[(\d+)\]$/', $value, $matches)) {
return (int) $matches[1];
}
// Otherwise return as integer
return (int) $value;
}
}
return null;
}
/**
* Extract port number from a field that may contain IP:port or IP[port] format
*/
protected function extractPortFromField(string $field): ?int
{
if (empty($field)) {
return null;
}
$field = trim($field);
// Handle IPv4 "IP:port" format (e.g., "45.154.99.22:22")
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) {
return (int)$matches[2];
}
// Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]")
if (preg_match('/\[(\d+)\]$/', $field, $matches)) {
return (int)$matches[1];
}
return null;
}
protected function expandIpValue(?string $value): array
{
$results = [];
if (!$value) {
return $results;
}
$sanitized = str_replace(["\r", "\n", "\t"], ' ', (string) $value);
$parts = preg_split('/[\s,]+/', $sanitized, -1, PREG_SPLIT_NO_EMPTY);
foreach ($parts as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
if (str_contains($part, '/')) {
[$ipCandidate, $prefixPart] = explode('/', $part, 2);
$normalizedIp = $this->normalizeSingleIp($ipCandidate);
$prefixPart = trim($prefixPart);
if ($normalizedIp === null || $prefixPart === '' || !ctype_digit($prefixPart)) {
continue;
}
$prefix = (int) $prefixPart;
$maxPrefix = str_contains($normalizedIp, ':') ? 128 : 32;
if ($prefix < 0 || $prefix > $maxPrefix) {
continue;
}
// Normalize CIDR to network address (important for matching)
$networkCidr = $this->normalizeCidr($normalizedIp, $prefix);
if ($networkCidr) {
// Add the base IP as an exact match (important for exact IP matching)
// This ensures IPs stored as "IP/CIDR" can be matched exactly
$results[] = [
'type' => 'ip',
'value' => $normalizedIp,
];
// Also add the CIDR network for CIDR matching
$results[] = [
'type' => 'cidr',
'value' => $networkCidr,
];
}
continue;
}
$normalizedIp = $this->normalizeSingleIp($part);
if ($normalizedIp !== null) {
$results[] = [
'type' => 'ip',
'value' => $normalizedIp,
];
}
}
return $results;
}
protected function normalizeSingleIp(?string $ip): ?string
{
if ($ip === null) {
return null;
}
$ip = strtolower(trim($ip));
if ($ip === '') {
return null;
}
$ip = trim($ip, "[]'\"");
if (str_contains($ip, '%')) {
$ip = strstr($ip, '%', true) ?: $ip;
}
return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null;
}
/**
* Normalize CIDR to network address (e.g., 45.154.99.28/28 -> 45.154.99.16/28)
*/
protected function normalizeCidr(string $ip, int $prefix): ?string
{
$isIPv6 = str_contains($ip, ':');
$ipBinary = @inet_pton($ip);
if ($ipBinary === false) {
return null;
}
$addressLength = strlen($ipBinary) * 8;
if ($prefix < 0 || $prefix > $addressLength) {
return null;
}
// Calculate network address by zeroing out host bits
$fullBytes = intdiv($prefix, 8);
$remainingBits = $prefix % 8;
// Zero out host bytes
for ($i = $fullBytes; $i < strlen($ipBinary); $i++) {
$ipBinary[$i] = "\0";
}
// Zero out remaining host bits in the partial byte
if ($remainingBits > 0 && $fullBytes < strlen($ipBinary)) {
$mask = ~((1 << (8 - $remainingBits)) - 1) & 0xFF;
$ipBinary[$fullBytes] = chr(ord($ipBinary[$fullBytes]) & $mask);
}
$networkIp = @inet_ntop($ipBinary);
if ($networkIp === false) {
return null;
}
return $networkIp . '/' . $prefix;
}
protected function ipMatchesServer(string $ip, array $exactIps): bool
{
$normalized = $this->normalizeSingleIp($ip);
if ($normalized === null) {
return false;
}
if (in_array($normalized, $exactIps, true)) {
return true;
}
foreach ($this->serverIpCidrs as $cidr) {
if ($this->ipInCidr($normalized, $cidr)) {
return true;
}
}
return false;
}
protected function ipInCidr(string $ip, string $cidr): bool
{
if (!str_contains($cidr, '/')) {
return $ip === $cidr;
}
[$network, $prefixLength] = explode('/', $cidr, 2);
$prefixLength = (int) $prefixLength;
$ipBinary = @inet_pton($ip);
$networkBinary = @inet_pton($network);
if ($ipBinary === false || $networkBinary === false) {
return false;
}
if (strlen($ipBinary) !== strlen($networkBinary)) {
return false;
}
$maxPrefix = strlen($networkBinary) * 8;
if ($prefixLength < 0 || $prefixLength > $maxPrefix) {
return false;
}
$fullBytes = intdiv($prefixLength, 8);
$remainingBits = $prefixLength % 8;
if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($networkBinary, 0, $fullBytes)) {
return false;
}
if ($remainingBits === 0) {
return true;
}
$mask = (~(0xff >> $remainingBits)) & 0xff;
return (ord($ipBinary[$fullBytes]) & $mask) === (ord($networkBinary[$fullBytes]) & $mask);
}
public function getGraphPayloads(): array
{
return $this->graphPayloads;
}
public function updateChartData(string $chartType): array
{
$timeRangeProperty = $chartType . 'TimeRange';
$aggregationProperty = $chartType . 'Aggregation';
$timeRange = $this->$timeRangeProperty;
$aggregation = $this->$aggregationProperty;
$this->fetchGraphData($chartType, $timeRange, $aggregation, true);
$fullData = $this->graphPayloads[$chartType] ?? [];
// Filter data to only return essential fields for this chart type to reduce response size
$filteredData = [];
foreach ($fullData as $point) {
$filteredPoint = ['time' => $point['time'] ?? 0];
switch ($chartType) {
case 'cpu':
$filteredPoint['cpu'] = $point['cpu'] ?? 0;
break;
case 'memory':
$filteredPoint['mem'] = $point['mem'] ?? 0;
$filteredPoint['maxmem'] = $point['maxmem'] ?? 0;
break;
case 'network':
$filteredPoint['netin'] = $point['netin'] ?? 0;
$filteredPoint['netout'] = $point['netout'] ?? 0;
break;
case 'disk':
$filteredPoint['diskread'] = $point['diskread'] ?? 0;
$filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0;
break;
}
$filteredData[] = $filteredPoint;
}
return $filteredData;
}
public function render()
{
return view('proxmox::statistics')->layoutData([
'sidebar' => true,
]);
}
public function exportChart(string $chartKey)
{
$rows = $this->exportRowsFor($chartKey);
if (empty($rows)) {
$this->showExportInfo('No data available to export for this chart. Please refresh the chart and try again.');
return;
}
$headers = array_keys($rows[0]);
$csv = implode(',', $headers) . PHP_EOL;
foreach ($rows as $row) {
$csv .= implode(',', array_map(function ($value) {
return '"' . str_replace('"', '""', (string)$value) . '"';
}, $row)) . PHP_EOL;
}
$filename = sprintf('%s-chart-%s.csv', strtolower($chartKey), now()->format('Y-m-d_Hi'));
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, $filename, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
protected function exportRowsFor(string $chartKey): array
{
$chartKey = strtolower($chartKey);
$data = $this->graphPayloads[$chartKey] ?? [];
if (empty($data)) {
return [];
}
$rows = [];
foreach ($data as $point) {
if (!isset($point['time'])) {
continue;
}
$timestamp = Carbon::createFromTimestamp((int)$point['time'])->toDateTimeString();
switch ($chartKey) {
case 'cpu':
$rows[] = [
'Timestamp' => $timestamp,
'CPU (%)' => round(($point['cpu'] ?? 0) * 100, 2),
];
break;
case 'memory':
$rows[] = [
'Timestamp' => $timestamp,
'Used (GB)' => $this->convertBytes($point['mem'] ?? 0, 'GB'),
'Max (GB)' => $this->convertBytes($point['maxmem'] ?? 0, 'GB'),
];
break;
case 'network':
$rows[] = [
'Timestamp' => $timestamp,
'Download (MB/s)' => $this->convertBytes($point['netin'] ?? 0, 'MB'),
'Upload (MB/s)' => $this->convertBytes($point['netout'] ?? 0, 'MB'),
];
break;
case 'disk':
$rows[] = [
'Timestamp' => $timestamp,
'Read (MB/s)' => $this->convertBytes($point['diskread'] ?? 0, 'MB'),
'Write (MB/s)' => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'),
];
break;
}
}
return $rows;
}
protected function makeCpuChart()
{
$data = $this->graphPayloads['cpu'] ?? [];
if (empty($data)) {
return null;
}
$labels = $this->buildLabels($data, $this->cpuTimeRange);
$values = array_map(fn ($point) => round(($point['cpu'] ?? 0) * 100, 2), $data);
return Chartjs::build()
->name('cpuChart_' . $this->server->id . '_' . $this->cpuTimeRange . '_' . $this->cpuAggregation)
->type('line')
->size(['height' => 280, 'width' => 400])
->labels($labels)
->datasets([
[
'label' => 'CPU Usage (%)',
'data' => $values,
'borderColor' => 'rgb(74, 222, 128)',
'backgroundColor' => 'rgba(74, 222, 128, 0.15)',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
],
])
->options($this->chartOptions(100, 'CPU %', false));
}
protected function makeMemoryChart()
{
$data = $this->graphPayloads['memory'] ?? [];
if (empty($data)) {
return null;
}
$labels = $this->buildLabels($data, $this->memoryTimeRange);
$used = array_map(fn ($point) => $this->convertBytes($point['mem'] ?? 0, 'GB'), $data);
$max = array_map(fn ($point) => $this->convertBytes($point['maxmem'] ?? 0, 'GB'), $data);
return Chartjs::build()
->name('memoryChart_' . $this->server->id . '_' . $this->memoryTimeRange . '_' . $this->memoryAggregation)
->type('line')
->size(['height' => 280, 'width' => 400])
->labels($labels)
->datasets([
[
'label' => 'Used (GB)',
'data' => $used,
'borderColor' => 'rgb(96, 165, 250)',
'backgroundColor' => 'rgba(96, 165, 250, 0.15)',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
],
[
'label' => 'Provisioned (GB)',
'data' => $max,
'borderColor' => 'rgb(30, 64, 175)',
'backgroundColor' => 'rgba(37, 99, 235, 0.08)',
'fill' => false,
'pointRadius' => 0,
'borderDash' => [6, 6],
],
])
->options($this->chartOptions(null, 'GB'));
}
protected function makeNetworkChart()
{
$data = $this->graphPayloads['network'] ?? [];
if (empty($data)) {
return null;
}
$labels = $this->buildLabels($data, $this->networkTimeRange);
$download = array_map(fn ($point) => $this->convertBytes($point['netin'] ?? 0, 'MB'), $data);
$upload = array_map(fn ($point) => $this->convertBytes($point['netout'] ?? 0, 'MB'), $data);
return Chartjs::build()
->name('networkChart_' . $this->server->id . '_' . $this->networkTimeRange . '_' . $this->networkAggregation)
->type('line')
->size(['height' => 280, 'width' => 400])
->labels($labels)
->datasets([
[
'label' => 'Download (MB/s)',
'data' => $download,
'borderColor' => 'rgb(45, 212, 191)',
'backgroundColor' => 'rgba(45, 212, 191, 0.12)',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
],
[
'label' => 'Upload (MB/s)',
'data' => $upload,
'borderColor' => 'rgb(14, 165, 233)',
'backgroundColor' => 'rgba(14, 165, 233, 0.12)',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
],
])
->options($this->chartOptions(null, 'MB/s'));
}
protected function makeDiskChart()
{
$data = $this->graphPayloads['disk'] ?? [];
if (empty($data)) {
return null;
}
$labels = $this->buildLabels($data, $this->diskTimeRange);
$read = array_map(fn ($point) => $this->convertBytes($point['diskread'] ?? 0, 'MB'), $data);
$write = array_map(fn ($point) => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'), $data);
return Chartjs::build()
->name('diskChart_' . $this->server->id . '_' . $this->diskTimeRange . '_' . $this->diskAggregation)
->type('line')
->size(['height' => 280, 'width' => 400])
->labels($labels)
->datasets([
[
'label' => 'Read (MB/s)',
'data' => $read,
'borderColor' => 'rgb(147, 51, 234)',
'backgroundColor' => 'rgba(147, 51, 234, 0.14)',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
],
[
'label' => 'Write (MB/s)',
'data' => $write,
'borderColor' => 'rgb(236, 72, 153)',
'backgroundColor' => 'rgba(236, 72, 153, 0.12)',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
],
])
->options($this->chartOptions(null, 'MB/s'));
}
protected function chartOptions(?int $suggestedMax = null, ?string $yLabel = null, bool $showLegend = true): array
{
$options = [
'responsive' => true,
'maintainAspectRatio' => false,
'plugins' => [
'legend' => [
'display' => $showLegend,
'labels' => [
'color' => 'rgb(148, 163, 184)',
'usePointStyle' => true,
],
],
'tooltip' => [
'backgroundColor' => 'rgba(15, 23, 42, 0.92)',
'titleColor' => '#fff',
'bodyColor' => '#fff',
],
],
'elements' => [
'point' => [
'hitRadius' => 20,
],
],
'scales' => [
'x' => [
'grid' => [
'color' => 'rgba(148, 163, 184, 0.12)',
],
'ticks' => [
'color' => 'rgb(148, 163, 184)',
'maxRotation' => 0,
],
],
'y' => [
'beginAtZero' => true,
'grid' => [
'color' => 'rgba(148, 163, 184, 0.08)',
],
'ticks' => [
'color' => 'rgb(148, 163, 184)',
],
],
],
];
if ($suggestedMax !== null) {
$options['scales']['y']['suggestedMax'] = $suggestedMax;
}
if ($yLabel) {
$options['scales']['y']['title'] = [
'display' => true,
'text' => $yLabel,
'color' => 'rgb(148, 163, 184)',
'font' => ['size' => 12],
];
}
return $options;
}
protected function buildLabels(array $data, string $range): array
{
$format = $this->labelFormatForRange($range);
return array_map(function ($point) use ($format) {
if (!isset($point['time'])) {
return '';
}
return Carbon::createFromTimestamp((int)$point['time'])->format($format);
}, $data);
}
protected function labelFormatForRange(string $range): string
{
return match ($range) {
'hour' => 'H:i',
'day' => 'M d H:i',
'week' => 'M d',
'month' => 'M d',
'year' => 'M Y',
default => 'M d H:i',
};
}
protected function convertBytes($bytes, string $unit = 'MB'): float
{
if (!is_numeric($bytes) || $bytes <= 0) {
return 0.0;
}
$unit = strtoupper($unit);
$divisors = [
'B' => 1,
'KB' => 1024,
'MB' => 1024 ** 2,
'GB' => 1024 ** 3,
'TB' => 1024 ** 4,
];
$divisor = $divisors[$unit] ?? $divisors['MB'];
return round($bytes / $divisor, 2);
}
/**
* Extract IP address from a string, handling ports for both IPv4 and IPv6
*/
protected function extractIPFromString(string $ip): string
{
$ip = trim($ip);
// Handle IPv4 with port (e.g., "192.168.1.1:8080")
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $ip, $matches)) {
return $matches[1];
}
// Handle IPv6 with port in brackets (e.g., "[2001:db8::1]:8080")
if (preg_match('/^\[([^\]]+)\]:(\d+)$/', $ip, $matches)) {
return $matches[1];
}
// Handle IPv6 with port without brackets (less common, but possible)
// For IPv6, we need to check if it's actually an IPv6 address before removing port
if (str_contains($ip, ':')) {
// Check if it's a valid IPv6 address
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
// No port to remove, it's just an IPv6 address
return $ip;
}
// Might be IPv6 with port, try to extract
$parts = explode(']:', $ip);
if (count($parts) === 2) {
return trim($parts[0], '[]');
}
}
return $ip;
}
/**
* Check if an IP address is public (not private/localhost) - public method for Blade templates
*/
public function checkIsPublicIP(string $ip): bool
{
return $this->isPublicIP($ip);
}
/**
* Check if an IP address is public (not private/localhost)
*/
protected function isPublicIP(string $ip): bool
{
$ip = $this->extractIPFromString($ip);
if (empty($ip)) {
return false;
}
// Check for localhost
if (in_array(strtolower($ip), ['localhost', '127.0.0.1', '::1'])) {
return false;
}
// Validate and check if it's a public IP
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
// IPv4: Check if it's not in private ranges
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
// IPv6: Check if it's not in private ranges
// Private IPv6 ranges:
// - ::1 (localhost)
// - fc00::/7 (Unique Local Address - ULA)
// - fe80::/10 (Link-local)
// - ::ffff:0:0/96 (IPv4-mapped IPv6)
// Check for link-local (fe80::/10 - addresses starting with fe80 through febf)
if (preg_match('/^fe[89ab][0-9a-fA-F]:/i', $ip)) {
return false;
}
// Check for ULA (fc00::/7 means fc00:: to fdff:ffff:...)
if (str_starts_with($ip, 'fc') || str_starts_with($ip, 'fd')) {
return false;
}
// Check for IPv4-mapped IPv6
if (str_starts_with($ip, '::ffff:')) {
$ipv4Part = substr($ip, 7);
return $this->isPublicIP($ipv4Part);
}
// If it's a valid IPv6 and not in private ranges, consider it public
return true;
}
return false;
}
/**
* Fetch IP geolocation data from ip-api.com via HTTP (server-side proxy)
* This avoids mixed content issues when the site is served over HTTPS
*/
public function fetchIPLocation($ip): array
{
if (empty($ip)) {
return ['status' => 'fail', 'message' => 'IP address is required'];
}
// Extract and validate IP address
$cleanIP = $this->extractIPFromString((string)$ip);
if (!$this->isPublicIP($cleanIP)) {
return ['status' => 'fail', 'message' => 'Not a valid public IP address'];
}
try {
// Use HTTP protocol as requested (not HTTPS)
$response = Http::timeout(5)->get("http://ip-api.com/json/{$cleanIP}");
if ($response->successful()) {
return $response->json();
}
return [
'status' => 'fail',
'message' => 'Failed to fetch location data: HTTP ' . $response->status()
];
} catch (\Exception $e) {
Log::error('Failed to fetch IP location', [
'ip' => $cleanIP,
'original_ip' => $ip,
'error' => $e->getMessage()
]);
return [
'status' => 'fail',
'message' => 'Failed to fetch location data: ' . $e->getMessage()
];
}
}
}