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
This commit is contained in:
2026-03-28 19:34:46 -04:00
parent 9f996035db
commit 68043f0416
39 changed files with 1106 additions and 5013 deletions

View File

@@ -1,9 +1,7 @@
<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
use Livewire\Attributes\On;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Models\ServerStatistics;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
@@ -17,23 +15,9 @@ class Overview extends Component
public $isEditingHostname = false;
public $hostnameError = '';
public $password = '';
public $password_confirmation = '';
public $isEditingPassword = false;
public $passwordError = '';
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 $notes = '';
public $isEditingNotes = false;
public $notesError = '';
@@ -46,7 +30,12 @@ class Overview extends Component
$this->loadVmStatus();
}
$this->hostname = $server->hostname ?? ($this->status->name ?? '');
$this->hostname = $server->hostname ?? ($this->status?->name ?? '');
$notice = request()->query('notice');
if ($notice === 'display_unavailable') {
$this->notify('Display settings are only available for virtual machines.', 'info');
}
// Load notes from Proxmox VM description
try {
@@ -68,136 +57,6 @@ class Overview extends Component
}
}
#[On('proxmox:stats')]
public function stats()
{
// Group graph types by their (timeRange, aggregation) to batch API calls.
// The Proxmox /rrddata endpoint returns ALL metrics in a single response,
// so we can serve multiple graph types from one API call when they share parameters.
$groups = [];
$graphConfigs = [
'cpu' => [$this->cpuTimeRange, $this->cpuAggregation],
'memory' => [$this->memoryTimeRange, $this->memoryAggregation],
'network' => [$this->networkTimeRange, $this->networkAggregation],
'disk' => [$this->diskTimeRange, $this->diskAggregation],
];
foreach ($graphConfigs as $graphType => [$timeRange, $aggregation]) {
$key = $timeRange . '|' . $aggregation;
$groups[$key]['timeRange'] = $timeRange;
$groups[$key]['aggregation'] = $aggregation;
$groups[$key]['types'][] = $graphType;
}
foreach ($groups as $group) {
$this->fetchGraphDataBatch($group['types'], $group['timeRange'], $group['aggregation']);
}
}
/**
* Fetch rrddata once and dispatch to multiple graph types that share the same parameters.
*/
protected function fetchGraphDataBatch(array $graphTypes, string $timeRange, string $aggregation): void
{
$allowedRanges = ['hour', 'day', 'week', 'month', 'year'];
$allowedAggs = ['average', 'maximum'];
if (!in_array($timeRange, $allowedRanges, true) || !in_array($aggregation, $allowedAggs, true)) {
return;
}
// Use a shared cache key for the raw API data (not per-graph-type)
$cacheKey = 'proxmox-stats-' . $this->server->id . '-rrd-' . $timeRange . '-' . $aggregation;
$data = Cache::remember($cacheKey, 60, function () use ($timeRange, $aggregation) {
$proxmox = new Proxmox;
$vmType = $this->server->node->vmType();
$stats = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/rrddata', data: [
'timeframe' => $timeRange,
'cf' => $aggregation === 'maximum' ? 'MAX' : 'AVERAGE',
]);
return $stats->json()['data'] ?? [];
});
foreach ($graphTypes as $graphType) {
// Also store per-graph-type cache keys for individual update/invalidation
$perGraphKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation;
Cache::put($perGraphKey, $data, 60);
$this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $data);
}
}
protected function fetchGraphData(string $graphType, string $timeRange, string $aggregation)
{
$allowedRanges = ['hour', 'day', 'week', 'month', 'year'];
$allowedAggs = ['average', 'maximum'];
if (!in_array($timeRange, $allowedRanges, true) || !in_array($aggregation, $allowedAggs, true)) {
return;
}
$cacheKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation;
$data = Cache::remember($cacheKey, 60, function () use ($timeRange, $aggregation) {
$proxmox = new Proxmox;
$vmType = $this->server->node->vmType();
$stats = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/rrddata', data: [
'timeframe' => $timeRange,
'cf' => $aggregation === 'maximum' ? 'MAX' : 'AVERAGE',
]);
return $stats->json()['data'] ?? [];
});
$this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $data);
}
public function updatedCpuTimeRange()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation);
$this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation);
}
public function updatedCpuAggregation()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation);
$this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation);
}
public function updatedMemoryTimeRange()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation);
$this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation);
}
public function updatedMemoryAggregation()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation);
$this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation);
}
public function updatedNetworkTimeRange()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation);
$this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation);
}
public function updatedNetworkAggregation()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation);
$this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation);
}
public function updatedDiskTimeRange()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation);
$this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation);
}
public function updatedDiskAggregation()
{
Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation);
$this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation);
}
protected function formatSeconds(?int $seconds): string
{
if (is_null($seconds) || $seconds < 0) {
@@ -247,11 +106,11 @@ class Overview extends Component
$tasks = $response->json()['data'] ?? [];
foreach ($tasks as $task) {
$type = strtolower($task['type'] ?? '');
if (in_array($type, ['qmstart', 'qmresume'], true)) {
if (in_array($type, ['qmstart', 'qmresume', 'pctstart', 'pctresume'], true)) {
$counts['starts']++;
} elseif (in_array($type, ['qmstop', 'qmshutdown'], true)) {
} elseif (in_array($type, ['qmstop', 'qmshutdown', 'pctstop', 'pctshutdown'], true)) {
$counts['shutdowns']++;
} elseif (in_array($type, ['qmreboot', 'qmreset'], true)) {
} elseif (in_array($type, ['qmreboot', 'qmreset', 'pctreboot', 'pctreset'], true)) {
$counts['reboots']++;
}
}
@@ -334,14 +193,14 @@ class Overview extends Component
public function startEditingHostname()
{
$this->isEditingHostname = true;
$this->hostname = $this->server->hostname ?? $this->status->name ?? '';
$this->hostname = $this->server->hostname ?? $this->status?->name ?? '';
$this->hostnameError = '';
}
public function cancelEditingHostname()
{
$this->isEditingHostname = false;
$this->hostname = $this->server->hostname ?? $this->status->name ?? '';
$this->hostname = $this->server->hostname ?? $this->status?->name ?? '';
$this->hostnameError = '';
}
@@ -423,26 +282,21 @@ class Overview extends Component
public function startEditingPassword()
{
$this->isEditingPassword = true;
$this->password = '';
$this->password_confirmation = '';
$this->passwordError = '';
}
public function cancelEditingPassword()
{
$this->isEditingPassword = false;
$this->password = '';
$this->password_confirmation = '';
$this->passwordError = '';
}
public function updatePassword()
protected function validatePasswordInput(string $password, string $passwordConfirmation): bool
{
// Validate password using same rules as registration
$validator = Validator::make(
[
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
'password' => $password,
'password_confirmation' => $passwordConfirmation,
],
[
'password' => 'required|string|min:8|confirmed',
@@ -455,49 +309,49 @@ class Overview extends Component
return false;
}
return true;
}
protected function closePasswordEditor(): void
{
$this->isEditingPassword = false;
$this->passwordError = '';
}
public function updatePassword(string $password = '', string $passwordConfirmation = '')
{
if (!$this->validatePasswordInput($password, $passwordConfirmation)) {
return false;
}
try {
// Update password in database
$this->server->password = $this->password;
$this->server->password = $password;
$this->server->save();
// Refresh the server model to get latest data
$this->server->refresh();
$this->notify('Password saved to database successfully', 'success');
$this->isEditingPassword = false;
$this->password = '';
$this->password_confirmation = '';
$this->passwordError = '';
$this->closePasswordEditor();
return true;
} catch (\Exception $e) {
$this->passwordError = 'Failed to save password: ' . $e->getMessage();
report($e);
$this->passwordError = 'Failed to save password. Please try again.';
$this->notify('Failed to save password', 'error');
return false;
}
}
public function applyPassword()
public function applyPassword(string $password = '', string $passwordConfirmation = '')
{
// Validate password first
$validator = Validator::make(
[
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
],
[
'password' => 'required|string|min:8|confirmed',
]
);
if ($validator->fails()) {
$errors = $validator->errors()->all();
$this->passwordError = implode(' ', $errors);
if (!$this->validatePasswordInput($password, $passwordConfirmation)) {
return;
}
try {
// Update password in database first
$this->server->password = $this->password;
$this->server->password = $password;
$this->server->save();
// Refresh the server model to get latest data
@@ -506,10 +360,7 @@ class Overview extends Component
// LXC containers don't support password change via config API
if ($this->server->node->is_lxc) {
$this->notify('Password saved to database. Note: LXC container passwords cannot be changed via API - use console access to change root password if needed.', 'warning');
$this->isEditingPassword = false;
$this->password = '';
$this->password_confirmation = '';
$this->passwordError = '';
$this->closePasswordEditor();
return;
}
@@ -519,17 +370,15 @@ class Overview extends Component
// For KVM: cipassword sets the password via cloud-init on next boot
$response = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/config', 'put', [
'cipassword' => $this->password,
'cipassword' => $password,
]);
// The request() method throws on error, so if we get here, it was successful
$this->notify('Password applied to Proxmox successfully. The new password will be configured on the next boot via cloud-init. Please reboot your server for the changes to take effect.', 'success');
$this->isEditingPassword = false;
$this->password = '';
$this->password_confirmation = '';
$this->passwordError = '';
$this->closePasswordEditor();
} catch (\Exception $e) {
$this->passwordError = 'Failed to apply password: ' . $e->getMessage();
report($e);
$this->passwordError = 'Failed to apply password. Please try again.';
$this->notify('Failed to apply password to Proxmox', 'error');
}
}