Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/Overview.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

436 lines
15 KiB
PHP

<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Illuminate\Support\Facades\Validator;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Models\ServerStatistics;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
use Carbon\Carbon;
class Overview extends Component
{
public Server $server;
public $hostname = '';
public $isEditingHostname = false;
public $hostnameError = '';
public $isEditingPassword = false;
public $passwordError = '';
public $notes = '';
public $isEditingNotes = false;
public $notesError = '';
public function mount(Server $server): void
{
parent::mount($server);
if (is_null($this->status)) {
$this->loadVmStatus();
}
$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 {
$proxmox = new Proxmox;
$vmType = $this->server->node->vmType();
$config = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/config')->json()['data'] ?? [];
$this->notes = $config['description'] ?? '';
} catch (\Exception $e) {
$this->notes = '';
}
}
public function checkStatus()
{
$currentStatus = $this->status?->status;
$this->loadVmStatus();
if ($this->status?->status != $currentStatus) {
$this->fastPolling = false;
}
}
protected function formatSeconds(?int $seconds): string
{
if (is_null($seconds) || $seconds < 0) {
return 'N/A';
}
if ($seconds < 60) {
return $seconds . 's';
}
if ($seconds < 3600) {
$minutes = floor($seconds / 60);
$remaining = $seconds % 60;
return sprintf('%dm %ds', $minutes, $remaining);
}
if ($seconds < 86400) {
$hours = floor($seconds / 3600);
$minutes = floor(($seconds % 3600) / 60);
return sprintf('%dh %dm', $hours, $minutes);
}
$days = floor($seconds / 86400);
$hours = floor(($seconds % 86400) / 3600);
return sprintf('%dd %dh', $days, $hours);
}
protected function getTaskEventCounts(): array
{
$counts = [
'starts' => 0,
'shutdowns' => 0,
'reboots' => 0,
];
try {
$proxmox = new Proxmox;
$response = $proxmox->request(
'/nodes/' . $this->server->node->name . '/tasks',
'get',
[
'vmid' => $this->server->vm_id,
'limit' => 200,
]
);
$tasks = $response->json()['data'] ?? [];
foreach ($tasks as $task) {
$type = strtolower($task['type'] ?? '');
if (in_array($type, ['qmstart', 'qmresume', 'pctstart', 'pctresume'], true)) {
$counts['starts']++;
} elseif (in_array($type, ['qmstop', 'qmshutdown', 'pctstop', 'pctshutdown'], true)) {
$counts['shutdowns']++;
} elseif (in_array($type, ['qmreboot', 'qmreset', 'pctreboot', 'pctreset'], true)) {
$counts['reboots']++;
}
}
} catch (\Throwable $e) {
// If task fetch fails we simply return zeros
}
return $counts;
}
public function getPowerEventStatsProperty(): array
{
// Compute uptime percentage from DB statistics
$stats = ServerStatistics::where('server_id', $this->server->id)
->orderBy('DateTime')
->limit(500)
->get(['vmstatus']);
$samples = $stats->count();
$runningSamples = 0;
foreach ($stats as $stat) {
if (strtolower($stat->vmstatus ?? '') === 'running') {
$runningSamples++;
}
}
$uptimePercent = 0;
if ($samples > 0) {
$uptimePercent = round(($runningSamples / $samples) * 100, 1);
} elseif ($this->status) {
$uptimePercent = strtolower($this->status->status ?? '') === 'running' ? 100 : 0;
}
// Get start/shutdown/reboot counts from Proxmox API (authoritative source)
$taskCounts = $this->getTaskEventCounts();
return [
'starts' => $taskCounts['starts'],
'shutdowns' => $taskCounts['shutdowns'],
'reboots' => $taskCounts['reboots'],
'samples' => $samples,
'running_samples' => $runningSamples,
'uptime_percent' => $uptimePercent,
];
}
public function getNetworkRateStatsProperty(): array
{
$stats = ServerStatistics::where('server_id', $this->server->id)
->orderByDesc('DateTime')
->take(2)
->get(['DateTime', 'netin', 'netout']);
if ($stats->count() < 2) {
return ['in_rate' => 0, 'out_rate' => 0];
}
$current = $stats->first();
$previous = $stats->last();
$currentTime = $current->DateTime ? Carbon::parse($current->DateTime) : null;
$previousTime = $previous->DateTime ? Carbon::parse($previous->DateTime) : null;
if (!$currentTime || !$previousTime) {
return ['in_rate' => 0, 'out_rate' => 0];
}
$seconds = max($currentTime->diffInSeconds($previousTime), 1);
$inDelta = max(0, ($current->netin ?? 0) - ($previous->netin ?? 0));
$outDelta = max(0, ($current->netout ?? 0) - ($previous->netout ?? 0));
return [
'in_rate' => $inDelta / $seconds,
'out_rate' => $outDelta / $seconds,
];
}
public function startEditingHostname()
{
$this->isEditingHostname = true;
$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->hostnameError = '';
}
public function updateHostname()
{
// Validate hostname format (RFC-1035 compliant)
$validator = Validator::make(
['hostname' => $this->hostname],
['hostname' => 'required|regex:/^(?!.*\.\.)[a-z0-9]([a-z0-9.-]{0,126}[a-z0-9])?$/']
);
if ($validator->fails()) {
$this->hostnameError = 'Hostname must be RFC-1035 compliant (lowercase letters, numbers, hyphens, and dots only).';
return false;
}
try {
// Update hostname in database
$this->server->hostname = $this->hostname;
$this->server->save();
// Refresh the server model to get latest data
$this->server->refresh();
// Update service property for the order
if ($this->server->service) {
$this->server->service->properties()->updateOrCreate(
['key' => 'hostname'],
[
'value' => $this->hostname,
'name' => 'Hostname',
]
);
}
$this->notify('Hostname saved to database successfully', 'success');
$this->isEditingHostname = false;
$this->hostnameError = '';
return true;
} catch (\Exception $e) {
$this->hostnameError = 'Failed to save hostname: ' . $e->getMessage();
$this->notify('Failed to save hostname', 'error');
return false;
}
}
public function applyHostname()
{
// First, save the hostname if it hasn't been saved
if ($this->server->hostname !== $this->hostname || !$this->server->hostname) {
if (!$this->updateHostname()) {
return; // Exit if save failed
}
}
try {
$proxmox = new Proxmox;
// Update VM name - Proxmox uses the VM name as the hostname for cloud-init
// Cloud-init will pick up the new VM name on the next boot
$vmType = $this->server->node->vmType();
$response = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/config', 'put', [
$this->server->node->is_lxc ? 'hostname' : 'name' => $this->hostname,
]);
// The request() method throws on error, so if we get here, it was successful
$this->notify('Hostname applied to Proxmox successfully. The VM name has been updated. The new hostname will be configured on the next boot via cloud-init.', 'success');
$this->isEditingHostname = false;
$this->hostnameError = '';
// Refresh status to get updated name
$this->status = (object) ($proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/status/current')->json()['data'] ?? []);
} catch (\Exception $e) {
$this->hostnameError = 'Failed to apply hostname: ' . $e->getMessage();
$this->notify('Failed to apply hostname to Proxmox', 'error');
}
}
public function startEditingPassword()
{
$this->isEditingPassword = true;
$this->passwordError = '';
}
public function cancelEditingPassword()
{
$this->isEditingPassword = false;
$this->passwordError = '';
}
protected function validatePasswordInput(string $password, string $passwordConfirmation): bool
{
$validator = Validator::make(
[
'password' => $password,
'password_confirmation' => $passwordConfirmation,
],
[
'password' => 'required|string|min:8|confirmed',
]
);
if ($validator->fails()) {
$errors = $validator->errors()->all();
$this->passwordError = implode(' ', $errors);
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 = $password;
$this->server->save();
// Refresh the server model to get latest data
$this->server->refresh();
$this->notify('Password saved to database successfully', 'success');
$this->closePasswordEditor();
return true;
} catch (\Exception $e) {
report($e);
$this->passwordError = 'Failed to save password. Please try again.';
$this->notify('Failed to save password', 'error');
return false;
}
}
public function applyPassword(string $password = '', string $passwordConfirmation = '')
{
if (!$this->validatePasswordInput($password, $passwordConfirmation)) {
return;
}
try {
// Update password in database first
$this->server->password = $password;
$this->server->save();
// Refresh the server model to get latest data
$this->server->refresh();
// 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->closePasswordEditor();
return;
}
// Apply password via Proxmox API using cloud-init (KVM only)
$proxmox = new Proxmox;
$vmType = $this->server->node->vmType();
// 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' => $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->closePasswordEditor();
} catch (\Exception $e) {
report($e);
$this->passwordError = 'Failed to apply password. Please try again.';
$this->notify('Failed to apply password to Proxmox', 'error');
}
}
public function startEditingNotes()
{
$this->isEditingNotes = true;
$this->notesError = '';
}
public function cancelEditingNotes()
{
$this->isEditingNotes = false;
$this->notesError = '';
// Reload notes from Proxmox
try {
$proxmox = new Proxmox;
$vmType = $this->server->node->vmType();
$config = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/config')->json()['data'] ?? [];
$this->notes = $config['description'] ?? '';
} catch (\Exception $e) {
$this->notes = '';
}
}
public function saveNotes()
{
try {
$proxmox = new Proxmox;
$vmType = $this->server->node->vmType();
// Save notes to Proxmox VM description (limit to 8KB to avoid bloating Proxmox config)
$notes = mb_substr($this->notes ?? '', 0, 8192);
$proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/config', 'put', [
'description' => $notes,
]);
$this->notify('Notes saved successfully', 'success');
$this->isEditingNotes = false;
$this->notesError = '';
} catch (\Exception $e) {
$this->notesError = 'Failed to save notes: ' . $e->getMessage();
$this->notify('Failed to save notes', 'error');
}
}
public function render()
{
return view('proxmox::overview')->layoutData([
'sidebar' => true,
]);
}
}