Files
Virtnet.bond/extensions/Servers/Proxmox/Http/Controllers/LiveTrafficController.php
Joseph.Rawlings 68043f0416 fix(proxmox): harden console sessions and firewall rollout safety
Reduce security and reliability risk across the Proxmox extension by removing password exposure from Livewire state, hardening console auth/cookie handling, and improving sanitized error paths. Improve firewall and router performance by adding safer lazy loading, bounded pagination, cross-server compatibility guards, and lower-overhead API workflows.

Made-with: Cursor
2026-03-28 19:34:46 -04:00

359 lines
16 KiB
PHP

<?php
namespace Paymenter\Extensions\Servers\Proxmox\Http\Controllers;
use Carbon\Carbon;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
class LiveTrafficController extends Controller
{
private const RATE_SCALE = 100 * 1024 * 1024; // 100 MiB/s default scale
public function __invoke(Server $server): JsonResponse
{
abort_if(auth()->guest() || (int) $server->user_id !== (int) auth()->id(), 403);
try {
$proxmox = new Proxmox;
// First, get the VM configuration to identify network interfaces
$configResponse = $proxmox->request(
'/nodes/' . $server->node->name . '/' . $server->node->vmType() . '/' . $server->vm_id . '/config'
);
$config = $configResponse->json()['data'] ?? [];
// Extract network interfaces from config
$interfaces = [];
foreach ($config as $key => $value) {
if (!preg_match('/^net\d+$/', (string) $key)) {
continue;
}
$netConfig = (string) $value;
preg_match('/bridge=([^,]+)/', $netConfig, $bridgeMatch);
preg_match('/^([^=]+)/', $netConfig, $modelMatch);
$interfaces[] = [
'id' => $key,
'name' => $key,
'bridge' => $bridgeMatch[1] ?? 'unknown',
'model' => $modelMatch[1] ?? 'virtio',
];
}
// Get RRD data once for all interfaces (more efficient)
$rrdResponse = null;
$rrdRows = null;
if (count($interfaces) > 0) {
try {
$rrdResponse = $proxmox->request(
'/nodes/' . $server->node->name . '/' . $server->node->vmType() . '/' . $server->vm_id . '/rrddata',
'get',
[
'timeframe' => 'hour',
'cf' => 'AVERAGE',
]
);
$rrdJson = $rrdResponse->json();
$rrdRows = $rrdJson['data'] ?? [];
} catch (\Throwable $e) {
// If RRD request fails, we'll handle it per interface
$rrdRows = [];
}
}
usort($interfaces, static fn (array $a, array $b) => strcmp($a['id'], $b['id']));
$primaryInterfaceId = $interfaces[0]['id'] ?? 'net0';
// Get RRD data for each interface
$interfaceData = [];
$totalInRate = 0;
$totalOutRate = 0;
foreach ($interfaces as $interface) {
try {
// RRD data uses netin0, netout0, netin1, netout1, etc.
$ifNum = str_replace('net', '', $interface['id']);
$dsNames = "netin{$ifNum},netout{$ifNum}";
$rows = $rrdRows;
if (!is_array($rows) || count($rows) === 0) {
// If no RRD data available, still create interface entry with zero rates
$interfaceData[] = [
'id' => $interface['id'],
'name' => 'Network Statistics',
'bridge' => $interface['bridge'],
'model' => $interface['model'],
'downloadRate' => 0,
'uploadRate' => 0,
'totalRate' => 0,
'totalIn' => 0,
'totalOut' => 0,
'history' => [
'download' => array_fill(0, 20, 0),
'upload' => array_fill(0, 20, 0),
],
];
continue;
}
// Get the latest row with valid data
$currentRow = null;
$netinKey = "netin{$ifNum}";
$netoutKey = "netout{$ifNum}";
// Try to find interface-specific data first
foreach (array_reverse($rows) as $row) {
if (isset($row[$netinKey], $row[$netoutKey])) {
$currentRow = $row;
break;
}
}
// If interface-specific data not found, use aggregate data as fallback for net0 only
// For other interfaces, use zeros to ensure each interface has unique data
if (!$currentRow) {
if ($interface['id'] === $primaryInterfaceId) {
// For net0, use aggregate data as fallback (since aggregate typically = net0)
foreach (array_reverse($rows) as $row) {
if (isset($row['netin'], $row['netout'])) {
$currentRow = $row;
$currentRow[$netinKey] = $row['netin'];
$currentRow[$netoutKey] = $row['netout'];
break;
}
}
}
// For other interfaces or if net0 fallback failed, use zeros
if (!$currentRow) {
$currentRow = [$netinKey => 0, $netoutKey => 0];
}
}
$inRate = (float) ($currentRow[$netinKey] ?? 0);
$outRate = (float) ($currentRow[$netoutKey] ?? 0);
$totalInRate += $inRate;
$totalOutRate += $outRate;
// Calculate history for sparkline (last 20 samples)
$history = [
'download' => [],
'upload' => []
];
$sampleCount = min(20, count($rows));
$samples = array_slice($rows, -$sampleCount);
// Check if interface-specific keys exist in ANY sample
// This is more lenient - if the keys exist in some samples, we'll use them
$hasInterfaceSpecificData = false;
foreach ($samples as $sample) {
if (isset($sample[$netinKey]) || isset($sample[$netoutKey])) {
$hasInterfaceSpecificData = true;
break;
}
}
// Use interface-specific data if available, otherwise use zeros or aggregate for net0 only
// This ensures each interface has its own unique graph data (not duplicated)
foreach ($samples as $sample) {
if ($hasInterfaceSpecificData && isset($sample[$netinKey], $sample[$netoutKey])) {
// Use interface-specific data when available
$history['download'][] = (float) $sample[$netinKey];
$history['upload'][] = (float) $sample[$netoutKey];
} else {
// For net0, use aggregate data as fallback (since aggregate typically = net0)
// For other interfaces, use zeros to ensure unique graphs per interface
if ($interface['id'] === $primaryInterfaceId) {
$history['download'][] = (float) ($sample['netin'] ?? 0);
$history['upload'][] = (float) ($sample['netout'] ?? 0);
} else {
// Use zeros for other interfaces without specific data
// This ensures each interface has its own unique graph (even if zero)
$history['download'][] = 0.0;
$history['upload'][] = 0.0;
}
}
}
// Get aggregate totals for net0 fallback (will be set later from status endpoint)
// For now, set per-interface totals
$totalIn = 0;
$totalOut = 0;
// For net0, we'll use aggregate totals (set later)
// For other interfaces, use zeros to ensure uniqueness
if ($interface['id'] !== $primaryInterfaceId) {
$totalIn = 0;
$totalOut = 0;
}
// Note: net0 totals will be set from aggregate data after status endpoint call
$interfaceData[] = [
'id' => $interface['id'],
'name' => 'Network Statistics',
'bridge' => $interface['bridge'],
'model' => $interface['model'],
'downloadRate' => $inRate,
'uploadRate' => $outRate,
'totalRate' => $inRate + $outRate,
'totalIn' => $totalIn,
'totalOut' => $totalOut,
'history' => $history,
];
} catch (\Throwable $e) {
// Skip this interface if there's an error
continue;
}
}
// If no interface-specific data, fall back to general RRD data
if (empty($interfaceData)) {
$rows = is_array($rrdRows) ? $rrdRows : [];
if (is_array($rows) && count($rows) > 0) {
$currentRow = null;
foreach (array_reverse($rows) as $row) {
if (isset($row['netin'], $row['netout'])) {
$currentRow = $row;
break;
}
}
if ($currentRow) {
$inRate = (float) ($currentRow['netin'] ?? 0);
$outRate = (float) ($currentRow['netout'] ?? 0);
// Calculate history
$history = [
'download' => [],
'upload' => []
];
$sampleCount = min(20, count($rows));
$samples = array_slice($rows, -$sampleCount);
foreach ($samples as $sample) {
$history['download'][] = (float) ($sample['netin'] ?? 0);
$history['upload'][] = (float) ($sample['netout'] ?? 0);
}
$interfaceData[] = [
'id' => 'net0',
'name' => 'Network Statistics',
'bridge' => 'default',
'model' => 'virtio',
'downloadRate' => $inRate,
'uploadRate' => $outRate,
'totalRate' => $inRate + $outRate,
'totalIn' => 0, // Will be set from aggregate data later
'totalOut' => 0, // Will be set from aggregate data later
'history' => $history,
];
$totalInRate = $inRate;
$totalOutRate = $outRate;
}
}
}
$timestamp = now();
// Calculate dynamic scale based on max rate
$maxRate = max($totalInRate, $totalOutRate);
$scale = self::RATE_SCALE;
if ($maxRate > 0) {
// Round up to nice numbers
if ($maxRate > 1000 * 1024 * 1024) { // > 1 GiB/s
$scale = ceil($maxRate / (100 * 1024 * 1024)) * (100 * 1024 * 1024);
} elseif ($maxRate > 100 * 1024 * 1024) { // > 100 MiB/s
$scale = ceil($maxRate / (50 * 1024 * 1024)) * (50 * 1024 * 1024);
} elseif ($maxRate > 10 * 1024 * 1024) { // > 10 MiB/s
$scale = ceil($maxRate / (10 * 1024 * 1024)) * (10 * 1024 * 1024);
} else {
$scale = max(10 * 1024 * 1024, ceil($maxRate / (1024 * 1024)) * (1024 * 1024));
}
}
// Get total statistics from status endpoint
$statusResponse = $proxmox->request(
'/nodes/' . $server->node->name . '/' . $server->node->vmType() . '/' . $server->vm_id . '/status/current'
);
$status = $statusResponse->json()['data'] ?? [];
$totalIn = (float) ($status['netin'] ?? 0);
$totalOut = (float) ($status['netout'] ?? 0);
// Update the first configured interface totals with aggregate data
// Other interfaces keep zeros to ensure uniqueness
foreach ($interfaceData as &$interface) {
if ($interface['id'] === $primaryInterfaceId) {
$interface['totalIn'] = $totalIn;
$interface['totalOut'] = $totalOut;
}
}
unset($interface); // Break reference
return response()->json([
'interfaces' => $interfaceData,
'total' => [
'downloadRate' => $totalInRate,
'uploadRate' => $totalOutRate,
'combinedRate' => $totalInRate + $totalOutRate,
'downloadTotal' => $totalIn,
'uploadTotal' => $totalOut,
'combinedTotal' => $totalIn + $totalOut,
],
'scale' => $scale,
'updatedAt' => $timestamp->toIso8601String(),
'timestamp' => $timestamp->timestamp,
]);
} catch (RequestException $e) {
return response()->json([
'interfaces' => [],
'total' => [
'downloadRate' => 0,
'uploadRate' => 0,
'combinedRate' => 0,
'downloadTotal' => 0,
'uploadTotal' => 0,
'combinedTotal' => 0,
],
'scale' => self::RATE_SCALE,
'updatedAt' => now()->toIso8601String(),
'timestamp' => now()->timestamp,
'error' => 'Unable to load live traffic from hypervisor.',
'code' => 'proxmox_request_failed',
], 502);
} catch (\Throwable $e) {
return response()->json([
'interfaces' => [],
'total' => [
'downloadRate' => 0,
'uploadRate' => 0,
'combinedRate' => 0,
'downloadTotal' => 0,
'uploadTotal' => 0,
'combinedTotal' => 0,
],
'scale' => self::RATE_SCALE,
'updatedAt' => now()->toIso8601String(),
'timestamp' => now()->timestamp,
'error' => 'An internal error occurred.',
'code' => 'internal_error',
], 500);
}
}
}