From 16cd5f05de4bf8acedb9cd203201efbf621fe360 Mon Sep 17 00:00:00 2001 From: "Joseph.Rawlings" Date: Tue, 24 Feb 2026 19:51:46 -0500 Subject: [PATCH] Enhance Proxmox views with improved loading states and UI updates - Updated serverplaceholder.blade.php to enhance loading skeletons for server details. - Added navigation breadcrumbs to statistics, subdomain, and system views for better user experience. - Improved loading indicators for CPU, network, memory, and disk charts in statistics.blade.php. - Refined storage.blade.php layout with section dividers and improved drive display, including bus type indicators and quota usage. - Enhanced VNC and Xterm popups with a new header design, loading animations, and reconnect functionality. - Added error handling for VNC and Xterm connections to inform users when unable to connect. --- config/services.php | 5 + .../IPAddressRelationManager.php | 16 +- .../Proxmox/Admin/Resources/NodeResource.php | 23 +- .../Admin/Resources/ServerResource.php | 11 +- .../Console/Commands/PollServerStatistics.php | 8 +- .../Http/Controllers/ServerTagController.php | 2 +- .../Servers/Proxmox/Jobs/ReinstallJob.php | 12 +- .../Proxmox/Livewire/Backups/Index.php | 17 +- .../Servers/Proxmox/Livewire/CloudInit.php | 18 ++ .../Servers/Proxmox/Livewire/Firewall.php | 63 +++- extensions/Servers/Proxmox/Livewire/Index.php | 4 +- .../Servers/Proxmox/Livewire/Statistics.php | 62 ++-- .../Servers/Proxmox/Livewire/Storage.php | 67 ++++- .../Servers/Proxmox/Livewire/Subdomain.php | 7 + .../Servers/Proxmox/Livewire/System.php | 23 ++ extensions/Servers/Proxmox/Livewire/VNC.php | 22 +- extensions/Servers/Proxmox/Livewire/XTerm.php | 22 +- extensions/Servers/Proxmox/Proxmox.php | 71 +++-- extensions/Servers/Proxmox/TechnitiumDns.php | 18 +- .../Servers/Proxmox/views/backups.blade.php | 30 +- .../Proxmox/views/boot-order.blade.php | 41 +-- .../Proxmox/views/cloud-init.blade.php | 15 +- .../Proxmox/views/components/layout.blade.php | 211 ++++++++++++- .../Servers/Proxmox/views/display.blade.php | 13 +- .../Servers/Proxmox/views/firewall.blade.php | 31 +- .../Servers/Proxmox/views/help.blade.php | 63 ++++ .../Servers/Proxmox/views/index.blade.php | 47 ++- .../Servers/Proxmox/views/network.blade.php | 35 +-- .../Servers/Proxmox/views/overview.blade.php | 278 ++++++------------ .../Servers/Proxmox/views/reinstall.blade.php | 48 ++- .../Servers/Proxmox/views/server.blade.php | 65 ++-- .../Proxmox/views/serverplaceholder.blade.php | 51 +++- .../Proxmox/views/statistics.blade.php | 95 +++++- .../Servers/Proxmox/views/storage.blade.php | 101 ++++--- .../Servers/Proxmox/views/subdomain.blade.php | 5 + .../Servers/Proxmox/views/system.blade.php | 36 ++- .../Servers/Proxmox/views/vnc-popup.blade.php | 123 +++++++- .../Servers/Proxmox/views/vnc.blade.php | 8 +- .../Proxmox/views/xterm-popup.blade.php | 123 +++++++- .../Servers/Proxmox/views/xterm.blade.php | 8 +- 40 files changed, 1423 insertions(+), 475 deletions(-) diff --git a/config/services.php b/config/services.php index 6bb68f6ae..83708fb2a 100644 --- a/config/services.php +++ b/config/services.php @@ -31,4 +31,9 @@ return [ ], ], + 'technitium' => [ + 'url' => env('TECHNITIUM_DNS_URL'), + 'token' => env('TECHNITIUM_DNS_TOKEN'), + ], + ]; diff --git a/extensions/Servers/Proxmox/Admin/Resources/IPPoolResource/RelationManagers/IPAddressRelationManager.php b/extensions/Servers/Proxmox/Admin/Resources/IPPoolResource/RelationManagers/IPAddressRelationManager.php index f5ca6ee58..397070b27 100644 --- a/extensions/Servers/Proxmox/Admin/Resources/IPPoolResource/RelationManagers/IPAddressRelationManager.php +++ b/extensions/Servers/Proxmox/Admin/Resources/IPPoolResource/RelationManagers/IPAddressRelationManager.php @@ -146,14 +146,28 @@ class IPAddressRelationManager extends RelationManager sort($ipAddresses); } // Create IP addresses in the pool + $created = 0; + $skipped = 0; foreach ($ipAddresses as $ip) { + // Skip if IP already exists in this pool + if ($this->getOwnerRecord()->ipAddresses()->where('ip', $ip)->exists()) { + $skipped++; + continue; + } $this->getOwnerRecord()->ipAddresses()->create([ 'type' => $data['type'], 'ip' => $ip, 'gateway' => $data['gateway'], 'netmask' => $data['netmask'], - 'mac_address' => $data['mac_address'] ?? null, ]); + $created++; + } + if ($skipped > 0) { + Notification::make() + ->title('Bulk Add Complete') + ->body("Created {$created} IPs, skipped {$skipped} duplicates.") + ->warning() + ->send(); } $this->dispatch('refresh'); }), diff --git a/extensions/Servers/Proxmox/Admin/Resources/NodeResource.php b/extensions/Servers/Proxmox/Admin/Resources/NodeResource.php index d800da486..a49d03d66 100644 --- a/extensions/Servers/Proxmox/Admin/Resources/NodeResource.php +++ b/extensions/Servers/Proxmox/Admin/Resources/NodeResource.php @@ -31,16 +31,21 @@ class NodeResource extends Resource $proxmox = new Proxmox; $nodes = once(function () use ($proxmox) { - $nodes = $proxmox->request('/cluster/resources', data: [ - 'type' => 'node', - ])->json()['data'] ?? []; + try { + $nodes = $proxmox->request('/cluster/resources', data: [ + 'type' => 'node', + ])->json()['data'] ?? []; - return collect($nodes)->map(function ($node) { - return [ - 'label' => $node['node'], - 'value' => $node['node'], - ]; - })->pluck('label', 'value'); + return collect($nodes)->map(function ($node) { + return [ + 'label' => $node['node'], + 'value' => $node['node'], + ]; + })->pluck('label', 'value'); + } catch (\Throwable $e) { + \Log::error('Failed to fetch Proxmox nodes: ' . $e->getMessage()); + return collect(); + } }); return $form diff --git a/extensions/Servers/Proxmox/Admin/Resources/ServerResource.php b/extensions/Servers/Proxmox/Admin/Resources/ServerResource.php index 3036a94bb..509b6deac 100644 --- a/extensions/Servers/Proxmox/Admin/Resources/ServerResource.php +++ b/extensions/Servers/Proxmox/Admin/Resources/ServerResource.php @@ -36,16 +36,7 @@ class ServerResource extends Resource ->searchable() ->reactive() ->disabledOn('edit') - ->afterStateUpdated(function (callable $set, $state) { - $set('os_id', null); - }), - Forms\Components\Select::make('node_name') - ->label('Node Common Name') - ->required() - ->relationship('node', 'notes') - ->searchable() - ->reactive() - ->disabledOn('edit') + ->helperText(fn ($record) => $record?->node ? 'Common Name: ' . $record->node->notes : null) ->afterStateUpdated(function (callable $set, $state) { $set('os_id', null); }), diff --git a/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php b/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php index 40d3ab4d2..751538e76 100644 --- a/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php +++ b/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php @@ -151,14 +151,14 @@ class PollServerStatistics extends Command if ($automaticStatsCount > $maxAutomaticStatistics) { $excessCount = $automaticStatsCount - $maxAutomaticStatistics; - $oldestStats = ServerStatistics::where('server_id', $server->id) + $idsToDelete = ServerStatistics::where('server_id', $server->id) ->where('is_automatic', true) ->orderBy('DateTime', 'asc') ->limit($excessCount) - ->get(); + ->pluck('id'); - foreach ($oldestStats as $oldStat) { - $oldStat->delete(); + if ($idsToDelete->isNotEmpty()) { + ServerStatistics::whereIn('id', $idsToDelete)->delete(); } } diff --git a/extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php b/extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php index f3e9abbd6..93f8c35d8 100644 --- a/extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php +++ b/extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php @@ -59,7 +59,7 @@ class ServerTagController } catch (\Exception $e) { return response()->json([ 'success' => false, - 'error' => $e->getMessage(), + 'error' => 'Failed to update tags.', ], 500); } } diff --git a/extensions/Servers/Proxmox/Jobs/ReinstallJob.php b/extensions/Servers/Proxmox/Jobs/ReinstallJob.php index ddf391c1a..9ecc51240 100644 --- a/extensions/Servers/Proxmox/Jobs/ReinstallJob.php +++ b/extensions/Servers/Proxmox/Jobs/ReinstallJob.php @@ -22,6 +22,11 @@ class ReinstallJob implements ShouldQueue public int $timeout = 600; // 10 minutes + /** + * Exponential backoff between retries (seconds). + */ + public array $backoff = [10, 30, 60, 120, 300]; + public function __construct(public Server $server) {} public function handle(): void @@ -44,7 +49,12 @@ class ReinstallJob implements ShouldQueue $this->server->node->name ); } catch (Exception $e) { - if (!str_contains($e->getMessage(), 'does not exist')) { + $msg = strtolower($e->getMessage()); + // Allow reinstall to proceed if VM doesn't exist or is already stopped + if (!str_contains($msg, 'does not exist') + && !str_contains($msg, 'not running') + && !str_contains($msg, 'already stopped') + && !str_contains($msg, 'is not running')) { throw $e; } } diff --git a/extensions/Servers/Proxmox/Livewire/Backups/Index.php b/extensions/Servers/Proxmox/Livewire/Backups/Index.php index ef59699f4..e045d57dd 100644 --- a/extensions/Servers/Proxmox/Livewire/Backups/Index.php +++ b/extensions/Servers/Proxmox/Livewire/Backups/Index.php @@ -77,9 +77,20 @@ class Index extends Component $proxmox = new Proxmox; // Stop the VM before restoring — Proxmox requires it - $proxmox->requestAndWait('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/stop', 'post', [ - 'vmid' => $this->server->vm_id, - ], $this->server->node->name); + try { + $proxmox->requestAndWait('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/stop', 'post', [ + 'vmid' => $this->server->vm_id, + ], $this->server->node->name); + } catch (\Exception $e) { + $msg = strtolower($e->getMessage()); + // If VM is already stopped, continue with restore; otherwise abort + if (!str_contains($msg, 'not running') + && !str_contains($msg, 'already stopped') + && !str_contains($msg, 'is not running')) { + $this->notify('Failed to stop VM before restore: ' . $e->getMessage(), 'error'); + return; + } + } $proxmox->request('/nodes/' . $this->server->node->name . '/qemu', 'post', data: [ 'vmid' => $this->server->vm_id, diff --git a/extensions/Servers/Proxmox/Livewire/CloudInit.php b/extensions/Servers/Proxmox/Livewire/CloudInit.php index a169bac0e..486cb9968 100644 --- a/extensions/Servers/Proxmox/Livewire/CloudInit.php +++ b/extensions/Servers/Proxmox/Livewire/CloudInit.php @@ -617,6 +617,19 @@ class CloudInit extends Component return; } + $validPrefixes = ['ssh-rsa ', 'ssh-ed25519 ', 'ecdsa-sha2-', 'ssh-dss ']; + $hasValidPrefix = false; + foreach ($validPrefixes as $prefix) { + if (str_starts_with($key, $prefix)) { + $hasValidPrefix = true; + break; + } + } + if (!$hasValidPrefix) { + $this->notify('Invalid SSH key format. Key must start with ssh-rsa, ssh-ed25519, ecdsa-sha2, or ssh-dss.', 'error'); + return; + } + $this->sshKeys[] = $key; $this->newSshKey = ''; } @@ -638,6 +651,11 @@ class CloudInit extends Component return; } + if (!filter_var($entry, FILTER_VALIDATE_IP)) { + $this->notify('Please enter a valid IPv4 or IPv6 address.', 'error'); + return; + } + $this->dnsServers[] = $entry; $this->newDnsServer = ''; } diff --git a/extensions/Servers/Proxmox/Livewire/Firewall.php b/extensions/Servers/Proxmox/Livewire/Firewall.php index 342cff935..b9d791c58 100644 --- a/extensions/Servers/Proxmox/Livewire/Firewall.php +++ b/extensions/Servers/Proxmox/Livewire/Firewall.php @@ -408,6 +408,13 @@ class Firewall extends Component public function toggleAdapterFirewall(string $adapter): void { + // Validate adapter name format to prevent arbitrary config key injection + if (!preg_match('/^net\d+$/', $adapter)) { + $this->error = 'Invalid network adapter name'; + $this->showErrorModal = true; + return; + } + $this->loading = true; try { @@ -531,6 +538,18 @@ class Firewall extends Component $this->loading = true; try { + $allowedPolicies = ['ACCEPT', 'DROP', 'REJECT']; + if (!in_array($this->policyIn, $allowedPolicies, true)) { + $this->error = 'Invalid inbound policy. Must be one of: ' . implode(', ', $allowedPolicies); + $this->showErrorModal = true; + return; + } + if (!in_array($this->policyOut, $allowedPolicies, true)) { + $this->error = 'Invalid outbound policy. Must be one of: ' . implode(', ', $allowedPolicies); + $this->showErrorModal = true; + return; + } + $response = $this->firewallRequest( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/firewall/options', 'put', @@ -649,6 +668,25 @@ class Firewall extends Component $this->loading = true; try { + $allowedActions = ['ACCEPT', 'DROP', 'REJECT']; + if (!in_array($this->ruleAction, $allowedActions, true)) { + $this->error = 'Invalid rule action. Must be one of: ' . implode(', ', $allowedActions); + $this->showErrorModal = true; + return; + } + + $allowedTypes = ['in', 'out', 'group']; + if (!in_array($this->ruleType, $allowedTypes, true)) { + $this->error = 'Invalid rule type. Must be one of: ' . implode(', ', $allowedTypes); + $this->showErrorModal = true; + return; + } + + $allowedLogLevels = ['nolog', 'emerg', 'alert', 'crit', 'err', 'warning', 'notice', 'info', 'debug']; + if (!in_array($this->ruleLog, $allowedLogLevels, true)) { + $this->ruleLog = 'nolog'; + } + $payload = [ 'action' => $this->ruleAction, 'type' => $this->ruleType, @@ -891,7 +929,7 @@ class Firewall extends Component public function saveAlias(): void { $this->validate([ - 'aliasName' => 'required|string|max:64', + 'aliasName' => 'required|string|max:64|regex:/^[a-zA-Z][a-zA-Z0-9_\-]*$/', 'aliasCidr' => 'required|string', ]); @@ -954,6 +992,13 @@ class Firewall extends Component return; } + // Validate alias name format to prevent path traversal + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $this->deletingAliasName)) { + $this->error = 'Invalid alias name format'; + $this->showErrorModal = true; + return; + } + $this->loading = true; $this->showDeleteAliasModal = false; @@ -1026,7 +1071,7 @@ class Firewall extends Component public function saveIPSet(): void { $this->validate([ - 'ipsetName' => 'required|string|max:64', + 'ipsetName' => 'required|string|max:64|regex:/^[a-zA-Z][a-zA-Z0-9_\-]*$/', ]); $this->loading = true; @@ -1078,6 +1123,13 @@ class Firewall extends Component return; } + // Validate IPSet name format to prevent path traversal + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $this->deletingIPSetName)) { + $this->error = 'Invalid IPSet name format'; + $this->showErrorModal = true; + return; + } + $this->loading = true; $this->showDeleteIPSetModal = false; @@ -1132,6 +1184,13 @@ class Firewall extends Component public function selectIPSet(string $name): void { + // Validate IPSet name format to prevent path traversal + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_\-]*$/', $name)) { + $this->error = 'Invalid IPSet name format'; + $this->showErrorModal = true; + return; + } + $this->selectedIPSetName = $name; $this->loadIPSetMembers($name); } diff --git a/extensions/Servers/Proxmox/Livewire/Index.php b/extensions/Servers/Proxmox/Livewire/Index.php index c58055e39..6549c913e 100644 --- a/extensions/Servers/Proxmox/Livewire/Index.php +++ b/extensions/Servers/Proxmox/Livewire/Index.php @@ -11,7 +11,9 @@ class Index extends Component public function render() { return view('proxmox::index', [ - 'servers' => Server::where('user_id', Auth::id())->get(), + 'servers' => Server::where('user_id', Auth::id()) + ->with(['primaryIpv4', 'primaryIpv6', 'service.product', 'node']) + ->get(), ])->layoutData([ 'sidebar' => true, ]); diff --git a/extensions/Servers/Proxmox/Livewire/Statistics.php b/extensions/Servers/Proxmox/Livewire/Statistics.php index 39cb86ca8..bf562f876 100644 --- a/extensions/Servers/Proxmox/Livewire/Statistics.php +++ b/extensions/Servers/Proxmox/Livewire/Statistics.php @@ -1948,13 +1948,17 @@ class Statistics extends Component public function getHistoricalComparisonExportDataProperty() { $comparison = $this->historicalComparison; - if (!$comparison) { + if (!$comparison || isset($comparison['error'])) { return []; } - $current = $comparison['current']; - $historical = $comparison['historical']; - $diff = $comparison['differences']; + $current = $comparison['current'] ?? null; + $historical = $comparison['historical'] ?? null; + $diff = $comparison['differences'] ?? null; + + if (!$current || !$historical || !$diff) { + return []; + } $historicalDateTime = $historical->DateTime->format('Y-m-d H:i:s'); $currentDateTime = $current->DateTime->format('Y-m-d H:i:s'); @@ -3479,25 +3483,38 @@ class Statistics extends Component 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', + // Check dedicated port fields first, then fall back to composite IP[port] fields + $portOnlyCandidates = [ "{$direction}_port", "{$direction}port", $direction === 'src' ? 'source_port' : 'destination_port', $direction === 'src' ? 'sport' : 'dport', ]; - foreach ($candidates as $candidate) { - if (isset($state[$candidate])) { + foreach ($portOnlyCandidates as $candidate) { + if (isset($state[$candidate]) && is_numeric($state[$candidate])) { + return (int) $state[$candidate]; + } + } + + // Fall back to extracting port from composite "IP[port]" or "IP:port" fields + $compositeCandidates = [ + $direction === 'src' ? 'source' : 'destination', + ]; + + foreach ($compositeCandidates as $candidate) { + if (isset($state[$candidate]) && is_string($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)) { + + // Handle "IP[port]" format + if (preg_match('/\[(\d+)\]$/', $value, $matches)) { return (int) $matches[1]; } - - // Otherwise return as integer - return (int) $value; + + // Handle IPv4 "IP:port" format (not IPv6) + if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $value, $matches)) { + return (int) $matches[2]; + } } } @@ -3631,17 +3648,17 @@ class Statistics extends Component $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 + // Apply mask to the partial byte first (before zeroing), preserving network bits if ($remainingBits > 0 && $fullBytes < strlen($ipBinary)) { $mask = ~((1 << (8 - $remainingBits)) - 1) & 0xFF; $ipBinary[$fullBytes] = chr(ord($ipBinary[$fullBytes]) & $mask); } + // Zero out all host bytes after the partial byte + for ($i = $fullBytes + ($remainingBits > 0 ? 1 : 0); $i < strlen($ipBinary); $i++) { + $ipBinary[$i] = "\0"; + } + $networkIp = @inet_ntop($ipBinary); if ($networkIp === false) { return null; @@ -3718,6 +3735,11 @@ class Statistics extends Component public function updateChartData(string $chartType): array { + $allowedTypes = ['cpu', 'memory', 'network', 'disk']; + if (!in_array($chartType, $allowedTypes, true)) { + return []; + } + $timeRangeProperty = $chartType . 'TimeRange'; $aggregationProperty = $chartType . 'Aggregation'; diff --git a/extensions/Servers/Proxmox/Livewire/Storage.php b/extensions/Servers/Proxmox/Livewire/Storage.php index 7349e52f3..1e2bfb091 100644 --- a/extensions/Servers/Proxmox/Livewire/Storage.php +++ b/extensions/Servers/Proxmox/Livewire/Storage.php @@ -514,18 +514,40 @@ class Storage extends Component */ public function addDrive(): void { + // Validate bus type against allowed values to prevent config key injection + if (!array_key_exists($this->newDriveBus, $this->busTypes)) { + $this->notify('Invalid bus type selected.', 'error'); + return; + } + + // Validate cache mode against allowed values to prevent config value injection + $allowedCaches = array_column($this->cacheOptions, 'value'); + if (!in_array($this->newDriveCache, $allowedCaches, true)) { + $this->notify('Invalid cache mode selected.', 'error'); + return; + } + + // Validate AIO type against allowed values to prevent config value injection + $allowedAios = array_column($this->aioOptions, 'value'); + if (!in_array($this->newDriveAio, $allowedAios, true)) { + $this->notify('Invalid async IO type selected.', 'error'); + return; + } + // Validate size if ($this->newDriveSize < 1) { $this->notify('Drive size must be at least 1 GB.', 'error'); return; } - // Check storage quota - $newTotal = $this->totalAllocatedStorage + $this->newDriveSize; - if ($newTotal > $this->maxAllowedStorage) { - $remaining = $this->maxAllowedStorage - $this->totalAllocatedStorage; - $this->notify("Adding this drive would exceed your storage quota. You have {$remaining} GB remaining.", 'error'); - return; + // Check storage quota (skip if no limit is set, i.e. maxAllowedStorage is 0) + if ($this->maxAllowedStorage > 0) { + $newTotal = $this->totalAllocatedStorage + $this->newDriveSize; + if ($newTotal > $this->maxAllowedStorage) { + $remaining = max(0, $this->maxAllowedStorage - $this->totalAllocatedStorage); + $this->notify("Adding this drive would exceed your storage quota. You have {$remaining} GB remaining.", 'error'); + return; + } } // Get next available index @@ -644,6 +666,20 @@ class Storage extends Component return; } + // Validate cache mode against allowed values to prevent config value injection + $allowedCaches = array_column($this->cacheOptions, 'value'); + if (!in_array($this->editDriveCache, $allowedCaches, true)) { + $this->notify('Invalid cache mode selected.', 'error'); + return; + } + + // Validate AIO type against allowed values to prevent config value injection + $allowedAios = array_column($this->aioOptions, 'value'); + if (!in_array($this->editDriveAio, $allowedAios, true)) { + $this->notify('Invalid async IO type selected.', 'error'); + return; + } + $drive = $this->drives[$this->editingDriveKey]; $busType = $drive['bus']; @@ -840,6 +876,12 @@ class Storage extends Component $drive = $this->drives[$this->movingDriveKey]; $targetBus = $this->moveDriveTargetBus; + // Validate target bus type against allowed values + if (!array_key_exists($targetBus, $this->busTypes)) { + $this->notify('Invalid target bus type.', 'error'); + return; + } + // Prevent moving to the same bus type if ($drive['bus'] === $targetBus) { $this->notify('Drive is already on the ' . $this->busTypes[$targetBus]['label'] . ' bus.', 'error'); @@ -1080,6 +1122,12 @@ class Storage extends Component $unusedDrive = $this->unusedDrives[$this->reattachingDriveKey]; $targetBus = $this->reattachTargetBus; + // Validate target bus type against allowed values + if (!array_key_exists($targetBus, $this->busTypes)) { + $this->notify('Invalid target bus type.', 'error'); + return; + } + // Reattaching doesn't add new storage — unused drives are already counted in totalAllocatedStorage. // Only block if total storage strictly exceeds the quota (which would indicate a data inconsistency). if ($this->totalAllocatedStorage > $this->maxAllowedStorage && $this->maxAllowedStorage > 0) { @@ -1209,6 +1257,13 @@ class Storage extends Component */ public function saveScsiController(): void { + // Validate SCSI controller type against allowed values + $allowedControllers = array_column($this->scsiControllerOptions, 'value'); + if (!in_array($this->scsiControllerType, $allowedControllers, true)) { + $this->notify('Invalid SCSI controller type.', 'error'); + return; + } + if ($this->scsiControllerType === $this->originalScsiControllerType) { $this->notify('No changes to save.', 'info'); return; diff --git a/extensions/Servers/Proxmox/Livewire/Subdomain.php b/extensions/Servers/Proxmox/Livewire/Subdomain.php index cf825bdf0..445802bb4 100644 --- a/extensions/Servers/Proxmox/Livewire/Subdomain.php +++ b/extensions/Servers/Proxmox/Livewire/Subdomain.php @@ -408,6 +408,13 @@ class Subdomain extends Component return; } + // Verify the IP address belongs to this server + $ipBelongsToServer = $this->server->ipAddresses->contains('id', $this->ipAddressIdToDelete); + if (!$ipBelongsToServer) { + $this->notify('Unauthorized: IP address does not belong to this server.', 'error'); + return; + } + try { $dns = new TechnitiumDns; if ($dns->deleteZone($this->subdomainToDelete, $this->ipAddressIdToDelete)) { diff --git a/extensions/Servers/Proxmox/Livewire/System.php b/extensions/Servers/Proxmox/Livewire/System.php index 84077c4ba..301de0eab 100644 --- a/extensions/Servers/Proxmox/Livewire/System.php +++ b/extensions/Servers/Proxmox/Livewire/System.php @@ -310,6 +310,22 @@ class System extends Component public function saveSystemSettings(): void { + // Validate biosType against allowed options + $allowedBios = array_column($this->availableBiosOptions, 'value'); + if ($this->biosType !== '' && !in_array($this->biosType, $allowedBios, true)) { + $this->notify('Invalid BIOS type selected.', 'error'); + return; + } + + // Validate cpuType against allowed options + if ($this->cpuType !== '') { + $allowedCpuTypes = array_column($this->availableCpuTypes, 'value'); + if (!empty($allowedCpuTypes) && !in_array($this->cpuType, $allowedCpuTypes, true)) { + $this->notify('Invalid CPU type selected.', 'error'); + return; + } + } + $flags = $this->formatFlagsForSave($this->selectedCpuFlags); $resolvedCpu = $this->cpuType !== '' ? $this->cpuType : null; @@ -616,6 +632,13 @@ class System extends Component return; } + // Validate TPM version against allowed values + $allowedVersions = ['v1.2', 'v2.0']; + if (!in_array($version, $allowedVersions, true)) { + $this->notify('Invalid TPM version. Must be v1.2 or v2.0.', 'error'); + return; + } + $volumeName = 'vm-' . $this->server->vm_id . '-tpm'; try { diff --git a/extensions/Servers/Proxmox/Livewire/VNC.php b/extensions/Servers/Proxmox/Livewire/VNC.php index e127643c7..1fb276967 100644 --- a/extensions/Servers/Proxmox/Livewire/VNC.php +++ b/extensions/Servers/Proxmox/Livewire/VNC.php @@ -19,12 +19,22 @@ class VNC extends Component public function render() { - $proxmox = new Proxmox; + try { + $proxmox = new Proxmox; - return view('proxmox::vnc', [ - 'vnc' => $proxmox->vnc($this->server), - ])->layoutData([ - 'sidebar' => true, - ]); + return view('proxmox::vnc', [ + 'vnc' => $proxmox->vnc($this->server), + ])->layoutData([ + 'sidebar' => true, + ]); + } catch (\Throwable $e) { + $this->notify('Failed to connect to VNC console: ' . $e->getMessage(), 'error'); + + return view('proxmox::vnc', [ + 'vnc' => null, + ])->layoutData([ + 'sidebar' => true, + ]); + } } } diff --git a/extensions/Servers/Proxmox/Livewire/XTerm.php b/extensions/Servers/Proxmox/Livewire/XTerm.php index b325f5370..034dbf068 100644 --- a/extensions/Servers/Proxmox/Livewire/XTerm.php +++ b/extensions/Servers/Proxmox/Livewire/XTerm.php @@ -19,13 +19,23 @@ class XTerm extends Component public function render() { - $proxmox = new Proxmox; + try { + $proxmox = new Proxmox; - return view('proxmox::xterm', [ - 'xterm' => $proxmox->xterm($this->server), - ])->layoutData([ - 'sidebar' => true, - ]); + return view('proxmox::xterm', [ + 'xterm' => $proxmox->xterm($this->server), + ])->layoutData([ + 'sidebar' => true, + ]); + } catch (\Throwable $e) { + $this->notify('Failed to connect to console: ' . $e->getMessage(), 'error'); + + return view('proxmox::xterm', [ + 'xterm' => null, + ])->layoutData([ + 'sidebar' => true, + ]); + } } } diff --git a/extensions/Servers/Proxmox/Proxmox.php b/extensions/Servers/Proxmox/Proxmox.php index 5181fdbae..ff00eef7b 100644 --- a/extensions/Servers/Proxmox/Proxmox.php +++ b/extensions/Servers/Proxmox/Proxmox.php @@ -487,9 +487,21 @@ class Proxmox extends ClassServer return; } - $this->syncServerSettings($server, $settings, $password); + try { + $this->syncServerSettings($server, $settings, $password); + } catch (\Throwable $e) { + \Log::error('Proxmox VM sync settings failed for server ' . $server->id, [ + 'error' => $e->getMessage(), + 'vm_id' => $server->vm_id, + 'node' => $server->node->name, + ]); + $server->status = 'error'; + $server->save(); - // Start the server + return; + } + + // Mark server as active $server->status = 'active'; $server->password = $password; $server->save(); @@ -604,7 +616,14 @@ class Proxmox extends ClassServer $diskNeeded = (int) $settings['disk']; if ($location->server_placement == 'random') { - $node = $location->nodes()->inRandomOrder()->first(); + $nodes = $location->nodes()->inRandomOrder()->get(); + $node = null; + foreach ($nodes as $n) { + if ($n->canStoreServer($memoryNeeded, $diskNeeded, $cpuNeeded)) { + $node = $n; + break; + } + } } elseif ($location->server_placement == 'fill') { // Get the nodes that have enough resources $nodes = $location->nodes()->get(); @@ -864,16 +883,11 @@ class Proxmox extends ClassServer $ipv6 = $this->getAvailableIps($node, $settings['ipv6'], 'ipv6'); } - // Get the next VMID by finding the maximum existing VMID and adding 1 - // This ensures we never reuse old VMIDs from deleted servers - $clusterResources = $this->request('/cluster/resources', 'get', ['type' => 'vm'])->json()['data'] ?? []; - $maxVmId = 99; // Default minimum VMID in Proxmox is 100 - foreach ($clusterResources as $resource) { - if (isset($resource['vmid']) && $resource['vmid'] > $maxVmId) { - $maxVmId = (int) $resource['vmid']; - } + // Use Proxmox's atomic nextid API to avoid VMID collisions during concurrent provisioning + $vm_id = (int) ($this->request('/cluster/nextid')->json()['data'] ?? 0); + if ($vm_id < 100) { + throw new Exception('Failed to obtain a valid VMID from Proxmox (got: ' . $vm_id . ')'); } - $vm_id = $maxVmId + 1; $password = Str::password(16); @@ -926,10 +940,17 @@ class Proxmox extends ClassServer return false; } - // Stop server - $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/stop', 'post', [ + if (!$server->node) { + \Log::error('Proxmox suspendServer: node relationship is null for server ' . $server->id); + $server->status = 'suspended'; + $server->save(); + return true; + } + + // Stop server and wait for completion + $this->requestAndWait('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/stop', 'post', [ 'vmid' => $server->vm_id, - ]); + ], $server->node->name); $server->status = 'suspended'; $server->save(); @@ -973,12 +994,23 @@ class Proxmox extends ClassServer } $server = Server::where('service_id', $service->id)->first(); + + if (!$server->node) { + \Log::error('Proxmox terminateServer: node relationship is null for server ' . $server->id); + // Release IPs and delete server record even if node is gone + $server->ipAddresses()->update(['server_id' => null]); + $server->delete(); + return true; + } + // Stop the VM and wait for completion before deleting $this->requestAndWait('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/stop', 'post', [ 'vmid' => $server->vm_id, ], $server->node->name); // Delete proxmox server $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id, 'delete'); + // Release IP addresses so they can be reallocated + $server->ipAddresses()->update(['server_id' => null]); // Delete server $server->delete(); @@ -994,11 +1026,16 @@ class Proxmox extends ClassServer */ public function upgradeServer(Service $service, $settings, $properties) { - if (!Server::where('service_id', $service->id)->exists()) { + $server = Server::where('service_id', $service->id)->first(); + + if (!$server) { return false; } - $server = Server::where('service_id', $service->id)->first(); + if (!$server->node) { + \Log::error('Proxmox upgradeServer: node relationship is null for server ' . $server->id); + return false; + } // Update the server settings $this->syncServerSettings($server, $settings, null); diff --git a/extensions/Servers/Proxmox/TechnitiumDns.php b/extensions/Servers/Proxmox/TechnitiumDns.php index e89f54163..7eb3b3b9f 100644 --- a/extensions/Servers/Proxmox/TechnitiumDns.php +++ b/extensions/Servers/Proxmox/TechnitiumDns.php @@ -13,8 +13,12 @@ class TechnitiumDns public function __construct() { - $this->baseUrl = 'https://dns.virtnet.bond'; - $this->token = 'fab3cb86382687472062c22b37aa5789bebba1dbe556017a7234a295aa5b586d'; + $this->baseUrl = rtrim((string) config('services.technitium.url', ''), '/'); + $this->token = (string) config('services.technitium.token', ''); + + if (empty($this->baseUrl) || empty($this->token)) { + throw new \RuntimeException('Technitium DNS is not configured. Set TECHNITIUM_DNS_URL and TECHNITIUM_DNS_TOKEN in your environment.'); + } } /** @@ -27,9 +31,9 @@ class TechnitiumDns $params = array_merge(['token' => $this->token], $data); if ($method === 'get') { - return Http::get($url, $params); + return Http::timeout(10)->connectTimeout(5)->get($url, $params); } else { - return Http::asForm()->post($url, $params); + return Http::timeout(10)->connectTimeout(5)->asForm()->post($url, $params); } } @@ -135,6 +139,12 @@ class TechnitiumDns $recordJson = $recordResponse->json(); if ($recordJson['status'] !== 'ok') { + // Clean up the zone we just created since the record failed + try { + $this->request('/zones/delete', 'post', ['zone' => $fullDomain]); + } catch (\Exception $cleanupException) { + // Ignore cleanup errors + } return [ 'success' => false, 'message' => 'Failed to add DNS record.', diff --git a/extensions/Servers/Proxmox/views/backups.blade.php b/extensions/Servers/Proxmox/views/backups.blade.php index e59464912..78d3d0571 100644 --- a/extensions/Servers/Proxmox/views/backups.blade.php +++ b/extensions/Servers/Proxmox/views/backups.blade.php @@ -89,26 +89,36 @@ @php $backup = (object) $backup; @endphp -

{{ $backup->notes ?: '—' }}

+
+
+ +
+

{{ $backup->notes ?: '—' }}

+
{{ Illuminate\Support\Number::fileSize($backup->size, 2) }} - {{ date('Y-m-d H:i:s', $backup->ctime) }} + @php + $fullDate = !empty($backup->ctime) ? date('Y-m-d H:i:s', $backup->ctime) : 'Unknown'; + $relativeTime = !empty($backup->ctime) ? \Carbon\Carbon::createFromTimestamp($backup->ctime)->diffForHumans() : 'Unknown'; + @endphp + {{ $relativeTime }} + {{ $fullDate }}
@@ -117,8 +127,14 @@ @empty - - No backups available for this server. + +
+
+ +
+

No backups found

+

Backups capture the full state of your VM including disk data and configuration. Create one to protect against data loss.

+
@endforelse diff --git a/extensions/Servers/Proxmox/views/boot-order.blade.php b/extensions/Servers/Proxmox/views/boot-order.blade.php index 1d3da9696..a4852bb03 100644 --- a/extensions/Servers/Proxmox/views/boot-order.blade.php +++ b/extensions/Servers/Proxmox/views/boot-order.blade.php @@ -1,41 +1,10 @@ -
+
+ Overview + + Boot Order +
+
+ Overview + + Cloud-init +
+
@@ -578,6 +588,9 @@
+ +
+
diff --git a/extensions/Servers/Proxmox/views/components/layout.blade.php b/extensions/Servers/Proxmox/views/components/layout.blade.php index 6170a1efc..23fe6b825 100644 --- a/extensions/Servers/Proxmox/views/components/layout.blade.php +++ b/extensions/Servers/Proxmox/views/components/layout.blade.php @@ -53,9 +53,26 @@
- - {{ $server->hostname }} - +
+ + {{ $server->hostname }} + + @if($vmStatus === 'running') + + + + + @elseif($vmStatus === 'stopped') + + + + @elseif($vmStatus) + + + + + @endif +
@@ -1214,12 +1231,20 @@ }, body: JSON.stringify({ icon: this.icon, color: this.color, prefix: this.iconPrefix }) }) - .then(r => r.json()) + .then(r => { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }) .then(data => { if (data.success) { this.message = 'Icon saved!'; setTimeout(() => { this.closeModal(); location.reload(); }, 800); + } else { + this.message = 'Failed to save icon.'; } + }) + .catch(() => { + this.message = 'Network error. Please try again.'; }); } } @@ -1453,7 +1478,8 @@