diff --git a/extensions/Servers/Proxmox/Admin/Resources/LocationResource.php b/extensions/Servers/Proxmox/Admin/Resources/LocationResource.php index a7c5b6dc4..0d0455646 100644 --- a/extensions/Servers/Proxmox/Admin/Resources/LocationResource.php +++ b/extensions/Servers/Proxmox/Admin/Resources/LocationResource.php @@ -72,7 +72,7 @@ class LocationResource extends Resource ->required() ->numeric() ->unique(ignoreRecord: true, modifyRuleUsing: function (Unique $rule, Get $get, $record) { - return $rule->where('location_id', $get('id')); + return $rule->where('location_id', $get('../../id')); }) ->placeholder('Enter the VM ID'), ]) diff --git a/extensions/Servers/Proxmox/Admin/Resources/ServerResource/RelationManagers/Ipv4RelationManager.php b/extensions/Servers/Proxmox/Admin/Resources/ServerResource/RelationManagers/Ipv4RelationManager.php index 13b1c650e..4ae3f68c0 100644 --- a/extensions/Servers/Proxmox/Admin/Resources/ServerResource/RelationManagers/Ipv4RelationManager.php +++ b/extensions/Servers/Proxmox/Admin/Resources/ServerResource/RelationManagers/Ipv4RelationManager.php @@ -34,7 +34,7 @@ class Ipv4RelationManager extends RelationManager Tables\Actions\DissociateAction::make() ->label('Remove') ->before(function (Tables\Actions\DissociateAction $action, RelationManager $livewire) { - if ($livewire->ownerRecord->primary_ip == $action->getRecord()->id) { + if ($livewire->ownerRecord->primary_ipv4 == $action->getRecord()->id) { Notification::make() ->danger() ->title('You cannot remove the primary IP') diff --git a/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php b/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php index 9d48d09ac..40d3ab4d2 100644 --- a/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php +++ b/extensions/Servers/Proxmox/Console/Commands/PollServerStatistics.php @@ -31,15 +31,21 @@ class PollServerStatistics extends Command public function handle(): void { // Poll all servers regardless of status or power condition - $servers = Server::all(); - $this->info('Polling statistics for ' . $servers->count() . ' servers...'); + $serverCount = Server::count(); + $this->info('Polling statistics for ' . $serverCount . ' servers...'); $successCount = 0; $errorCount = 0; $maxAutomaticStatistics = 24; - foreach ($servers as $server) { + foreach (Server::with('node')->cursor() as $server) { try { + if (!$server->node) { + $this->warn('Server ID ' . $server->id . ' has no node assigned, skipping.'); + $errorCount++; + continue; + } + $proxmox = new Proxmox; $status = $proxmox->request( '/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/current' @@ -180,9 +186,7 @@ class PollServerStatistics extends Command $this->info('Bandwidth threshold: ' . $threshold . ' GiB'); $throttledCount = 0; - $servers = Server::all(); - - foreach ($servers as $server) { + foreach (Server::with('node')->cursor() as $server) { try { $monthlyTraffic = $this->calculateServerMonthlyTraffic($server); @@ -210,8 +214,7 @@ class PollServerStatistics extends Command return 10; } - $totalTraffic = 0; - $serverCount = 0; + $trafficValues = []; foreach ($allServers as $server) { $firstStat = ServerStatistics::where('server_id', $server->id) @@ -228,17 +231,18 @@ class PollServerStatistics extends Command $netinDiff = max(0, $latestStat->netin - $firstStat->netin); $netoutDiff = max(0, $latestStat->netout - $firstStat->netout); $totalBytes = $netinDiff + $netoutDiff; - $totalTraffic += $totalBytes / (1024 * 1024 * 1024); - $serverCount++; + $trafficValues[] = $totalBytes / (1024 * 1024 * 1024); } } - if ($serverCount === 0) { + if (empty($trafficValues)) { return 10; } - $averageBandwidth = $totalTraffic / $serverCount; - $threshold95Percent = $averageBandwidth * 0.95; + // Calculate actual 95th percentile by sorting and taking the value at the 95th rank + sort($trafficValues); + $index = (int) ceil(0.95 * count($trafficValues)) - 1; + $threshold95Percent = $trafficValues[$index]; return round(max($threshold95Percent, 10), 2); }); @@ -273,7 +277,7 @@ class PollServerStatistics extends Command { try { $proxmox = new Proxmox; - $config = $proxmox->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config')->json()['data']; + $config = $proxmox->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config')->json()['data'] ?? []; $updates = []; diff --git a/extensions/Servers/Proxmox/Http/Controllers/LiveTrafficController.php b/extensions/Servers/Proxmox/Http/Controllers/LiveTrafficController.php index 8b81d2d59..d01573639 100644 --- a/extensions/Servers/Proxmox/Http/Controllers/LiveTrafficController.php +++ b/extensions/Servers/Proxmox/Http/Controllers/LiveTrafficController.php @@ -364,8 +364,8 @@ class LiveTrafficController extends Controller 'scale' => self::RATE_SCALE, 'updatedAt' => now()->toIso8601String(), 'timestamp' => now()->timestamp, - 'error' => $e->getMessage(), - ]); + 'error' => 'An internal error occurred.', + ], 500); } } } \ No newline at end of file diff --git a/extensions/Servers/Proxmox/Http/Controllers/ServerIconController.php b/extensions/Servers/Proxmox/Http/Controllers/ServerIconController.php index f3bca2b91..7743ff1ce 100644 --- a/extensions/Servers/Proxmox/Http/Controllers/ServerIconController.php +++ b/extensions/Servers/Proxmox/Http/Controllers/ServerIconController.php @@ -28,7 +28,7 @@ class ServerIconController return response()->json(['success' => true]); } - public function get($serverId) + public function show($serverId) { $server = Server::where('id', $serverId) ->where('user_id', Auth::id()) diff --git a/extensions/Servers/Proxmox/Livewire/Backups/Index.php b/extensions/Servers/Proxmox/Livewire/Backups/Index.php index e98c7b852..ef59699f4 100644 --- a/extensions/Servers/Proxmox/Livewire/Backups/Index.php +++ b/extensions/Servers/Proxmox/Livewire/Backups/Index.php @@ -14,7 +14,7 @@ class Index extends Component public $showRestore = false; - public $confirmRestore = true; + public $confirmRestore = false; public bool $showDelete = false; @@ -75,6 +75,12 @@ 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); + $proxmox->request('/nodes/' . $this->server->node->name . '/qemu', 'post', data: [ 'vmid' => $this->server->vm_id, 'force' => true, @@ -121,20 +127,19 @@ class Index extends Component } $proxmox = new Proxmox; - $proxmox->requestAndWait('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/stop', 'post', [ - 'vmid' => $this->server->vm_id, - ], $this->server->node->name); - $response = $proxmox->request('/nodes/' . $this->server->node->name . '/storage/' . $this->server->node->backup_location . '/content/' . $volid, 'delete'); + try { + $response = $proxmox->request('/nodes/' . $this->server->node->name . '/storage/' . $this->server->node->backup_location . '/content/' . $volid, 'delete'); - if ($response->status() == 200) { $this->notify('Backup deleted successfully', 'success'); - $this->backups = array_filter($this->backups, fn ($backup) => $backup['volid'] !== $volid); + $this->backups = array_values(array_filter($this->backups, fn ($backup) => $backup['volid'] !== $volid)); $this->showDelete = false; $this->confirmDelete = false; $this->volid = null; - } else { - $this->notify('Failed to delete backup: ' . $response->json()['errors'][0]['message'], 'error'); + } catch (\Illuminate\Http\Client\RequestException $e) { + $errors = $e->response?->json()['errors'] ?? null; + $message = is_array($errors) ? implode(', ', array_values($errors)) : 'Unknown error'; + $this->notify('Failed to delete backup: ' . $message, 'error'); } } diff --git a/extensions/Servers/Proxmox/Livewire/BootOrder.php b/extensions/Servers/Proxmox/Livewire/BootOrder.php index 47690d6b5..2bc7d6708 100644 --- a/extensions/Servers/Proxmox/Livewire/BootOrder.php +++ b/extensions/Servers/Proxmox/Livewire/BootOrder.php @@ -91,6 +91,10 @@ class BootOrder extends Component public function saveOrder() { + // Validate boot order entries against available devices + $validDevices = array_keys($this->availableDevices); + $this->order = array_values(array_filter($this->order, fn ($device) => in_array($device, $validDevices, true))); + try { $proxmox = new Proxmox; @@ -134,8 +138,16 @@ class BootOrder extends Component $slot = null; $lastError = null; - // Try IDE slots first (ide0-ide4, up to 5 attempts) - for ($i = 0; $i < 5; $i++) { + // Fetch current VM config to check slot occupancy (rawConfig is protected and not persisted between requests) + try { + $currentConfig = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; + } catch (\Exception $e) { + $this->notify('Failed to fetch VM configuration: ' . $e->getMessage(), 'error'); + return; + } + + // Try IDE slots first (ide0-ide3, up to 4 valid IDE slots) + for ($i = 0; $i < 4; $i++) { $ideSlot = 'ide' . $i; // Skip if this slot already exists as a CD-ROM drive @@ -144,9 +156,9 @@ class BootOrder extends Component } // Skip if slot exists and is already configured (not empty) - if (isset($this->rawConfig[$ideSlot]) && $this->rawConfig[$ideSlot] !== '') { + if (isset($currentConfig[$ideSlot]) && $currentConfig[$ideSlot] !== '') { // Check if it's a CD-ROM drive - if (is_string($this->rawConfig[$ideSlot]) && str_contains($this->rawConfig[$ideSlot], 'media=cdrom')) { + if (is_string($currentConfig[$ideSlot]) && str_contains($currentConfig[$ideSlot], 'media=cdrom')) { // Already a CD-ROM, skip continue; } @@ -184,8 +196,8 @@ class BootOrder extends Component } // Skip if slot exists and is already configured - if (isset($this->rawConfig[$sataSlot]) && $this->rawConfig[$sataSlot] !== '') { - if (is_string($this->rawConfig[$sataSlot]) && str_contains($this->rawConfig[$sataSlot], 'media=cdrom')) { + if (isset($currentConfig[$sataSlot]) && $currentConfig[$sataSlot] !== '') { + if (is_string($currentConfig[$sataSlot]) && str_contains($currentConfig[$sataSlot], 'media=cdrom')) { continue; } continue; @@ -315,9 +327,9 @@ class BootOrder extends Component return; } - if ($drive) { - $this->selectedIsos[$drive] = 'none'; - } else { + // Always update selectedIsos for the target drive + $this->selectedIsos[$targetDrive] = 'none'; + if (!$drive) { $this->selectedIso = 'none'; } @@ -333,7 +345,7 @@ class BootOrder extends Component { try { $proxmox = new Proxmox; - $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data']; + $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; $this->rawConfig = $config; $this->availableDevices = $this->extractBootableDevices($config); diff --git a/extensions/Servers/Proxmox/Livewire/CloudInit.php b/extensions/Servers/Proxmox/Livewire/CloudInit.php index a839c4fa2..a169bac0e 100644 --- a/extensions/Servers/Proxmox/Livewire/CloudInit.php +++ b/extensions/Servers/Proxmox/Livewire/CloudInit.php @@ -103,11 +103,17 @@ class CloudInit extends Component public function applyCloudInit() { $originalOsType = $this->original['osType'] ?? ''; - $deleteOstype = false; + $originalCiUser = $this->original['ciUser'] ?? ''; + $deleteKeys = []; // If ostype is being cleared (empty) and was previously set, we need to delete it if ($this->osType === '' && $originalOsType !== '' && in_array($originalOsType, self::VALID_OS_TYPES, true)) { - $deleteOstype = true; + $deleteKeys[] = 'ostype'; + } + + // If ciuser is being cleared (empty) and was previously set, we need to delete it + if ($this->ciUser === '' && $originalCiUser !== '') { + $deleteKeys[] = 'ciuser'; } $payload = [ @@ -139,9 +145,9 @@ class CloudInit extends Component // Filter out null values, but keep empty strings (for clearing searchdomain and nameserver) $payload = array_filter($payload, fn ($value) => $value !== null); - // Add delete parameter if we need to clear ostype - if ($deleteOstype) { - $payload['delete'] = 'ostype'; + // Add delete parameter for keys that need to be explicitly removed + if (!empty($deleteKeys)) { + $payload['delete'] = implode(',', $deleteKeys); } if (empty($payload)) { @@ -250,7 +256,7 @@ class CloudInit extends Component return []; } - $keys = array_filter(array_map('urldecode', preg_split("/[\r\n]+/", $rawKeys))); + $keys = array_filter(array_map('trim', preg_split("/[\r\n]+/", urldecode($rawKeys)))); return array_values(array_filter($keys, function ($key) { $normalized = preg_replace('/\s+/', ' ', trim($key)); @@ -333,23 +339,24 @@ class CloudInit extends Component protected function buildIpConfigString(array $network): ?string { $parts = []; + $isDhcp = ($network['dhcp'] ?? false) === true; - if (($network['dhcp'] ?? false) === true) { + if ($isDhcp) { $parts[] = 'ip=dhcp'; } elseif (!empty($network['ip'])) { $parts[] = 'ip=' . trim($network['ip']); - } - if (!empty($network['gw'])) { - $parts[] = 'gw=' . trim($network['gw']); + if (!empty($network['gw'])) { + $parts[] = 'gw=' . trim($network['gw']); + } } if (!empty($network['ip6'])) { $parts[] = 'ip6=' . trim($network['ip6']); - } - if (!empty($network['gw6'])) { - $parts[] = 'gw6=' . trim($network['gw6']); + if (!empty($network['gw6'])) { + $parts[] = 'gw6=' . trim($network['gw6']); + } } if (empty($parts)) { diff --git a/extensions/Servers/Proxmox/Livewire/Component.php b/extensions/Servers/Proxmox/Livewire/Component.php index 191ce85fd..20b2ae83e 100644 --- a/extensions/Servers/Proxmox/Livewire/Component.php +++ b/extensions/Servers/Proxmox/Livewire/Component.php @@ -30,7 +30,7 @@ class Component extends BaseComponent public function mount(Server $server): void { $this->server = $server; - if ($server->user_id != auth()->id()) { + if ((int) $server->user_id !== (int) auth()->id()) { abort(403); } @@ -65,14 +65,9 @@ class Component extends BaseComponent ['vmid' => $this->server->vm_id] ); - if ($response->successful()) { - $this->notify('Action performed successfully', 'success'); - if (property_exists($this, 'fastPolling')) { - $this->fastPolling = true; - } - } else { - $message = $response->json()['errors'][0]['message'] ?? 'Unknown error'; - $this->notify('Action failed: ' . $message, 'error'); + $this->notify('Action performed successfully', 'success'); + if (property_exists($this, 'fastPolling')) { + $this->fastPolling = true; } } catch (\Throwable $e) { $this->notify('Action failed: ' . $e->getMessage(), 'error'); @@ -106,11 +101,7 @@ class Component extends BaseComponent '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' ); - if (!$configResponse->successful()) { - // If we can't check, allow the action (fail open) - return true; - } - + // Note: request() calls ->throw(), so response is always successful here $config = $configResponse->json()['data'] ?? []; $totalStorage = $this->calculateTotalStorage($config); @@ -234,8 +225,9 @@ class Component extends BaseComponent data: ['vmid' => $this->server->vm_id] ); - if (isset($storageContent['data']) && is_array($storageContent['data'])) { - foreach ($storageContent['data'] as $item) { + $data = $storageContent->json()['data'] ?? []; + if (is_array($data)) { + foreach ($data as $item) { if (isset($item['volid']) && $item['volid'] === $volume) { // Size is in bytes if (isset($item['size'])) { @@ -263,19 +255,8 @@ class Component extends BaseComponent '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/current' ); - if ($response->successful()) { - $data = $response->json()['data'] ?? null; - $this->status = $data ? (object) $data : null; - } else { - \Log::warning('Failed to load VM status for server ' . $this->server->id, [ - 'server_id' => $this->server->id, - 'vm_id' => $this->server->vm_id, - 'node' => $this->server->node->name, - 'status_code' => $response->status(), - 'response' => $response->json(), - ]); - $this->status = null; - } + $data = $response->json()['data'] ?? null; + $this->status = $data ? (object) $data : null; } catch (\Illuminate\Http\Client\ConnectionException $e) { // Timeout or connection error - don't log as error, just set status to null \Log::info('Connection timeout loading VM status for server ' . $this->server->id); diff --git a/extensions/Servers/Proxmox/Livewire/Display.php b/extensions/Servers/Proxmox/Livewire/Display.php index 682d373ca..db9fe7d57 100644 --- a/extensions/Servers/Proxmox/Livewire/Display.php +++ b/extensions/Servers/Proxmox/Livewire/Display.php @@ -70,7 +70,7 @@ class Display extends Component $proxmox = new Proxmox; $config = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' - )->json()['data']; + )->json()['data'] ?? []; [$type, $memory] = $this->parseDisplayString($config['vga'] ?? null); @@ -196,9 +196,11 @@ class Display extends Component { $options = $this->displayPresets; - if (!isset($options[$this->selectedDisplay])) { - $options[$this->selectedDisplay] = [ - 'label' => strtoupper($this->selectedDisplay) . ' (custom)', + // Only add the original loaded display type as a custom option, not player-controlled input + $originalDisplay = $this->original['selectedDisplay'] ?? null; + if ($originalDisplay && !isset($options[$originalDisplay])) { + $options[$originalDisplay] = [ + 'label' => strtoupper($originalDisplay) . ' (custom)', 'description' => 'Currently configured in Proxmox. You can keep it or choose another option.', 'supports_memory' => false, ]; diff --git a/extensions/Servers/Proxmox/Livewire/Firewall.php b/extensions/Servers/Proxmox/Livewire/Firewall.php index 02de467d5..eb4dfdda5 100644 --- a/extensions/Servers/Proxmox/Livewire/Firewall.php +++ b/extensions/Servers/Proxmox/Livewire/Firewall.php @@ -323,20 +323,19 @@ class Firewall extends Component protected function firewallRequest(string $url, string $method = 'get', array $data = []): \Illuminate\Http\Client\Response { - $baseUrl = 'https://pve.virtnet.bond'; - $user = 'root@pam'; - $tokenId = 'firewall'; - $secret = '0a439967-0a44-4b94-8906-c36b3a40cf68'; + $proxmox = new \Paymenter\Extensions\Servers\Proxmox\Proxmox; + $baseUrl = rtrim($proxmox->config('host'), '/'); + $authToken = 'PVEAPIToken=' . $proxmox->config('user') . '=' . $proxmox->config('token'); + $verifySsl = $proxmox->config('verify_ssl') ? true : false; - $fullUrl = rtrim($baseUrl, '/') . '/api2/json' . $url; - $authHeader = 'PVEAPIToken=' . $user . '!' . $tokenId . '=' . $secret; + $fullUrl = $baseUrl . '/api2/json' . $url; $acceptHeader = str_contains($url, '/firewall/log') ? 'application/json, text/plain, */*' : 'application/json'; $headers = [ - 'Authorization' => $authHeader, + 'Authorization' => $authToken, 'Accept' => $acceptHeader, ]; @@ -346,7 +345,7 @@ class Firewall extends Component $headers['Pragma'] = 'no-cache'; } - $request = Http::withHeaders($headers)->withoutVerifying(); + $request = Http::withHeaders($headers)->withOptions(['verify' => $verifySsl]); $response = match($method) { 'post' => $request->post($fullUrl, $data), @@ -1625,10 +1624,8 @@ class Firewall extends Component // ==================== NETWORK ANALYSIS INTEGRATION ==================== - // Cache for firewall states to avoid repeated API calls - protected ?array $cachedStates = null; - protected ?int $cacheTimestamp = null; - protected const STATE_CACHE_TTL = 30; // Cache states for 30 seconds + // Cache TTL for firewall states (seconds) + protected const STATE_CACHE_TTL = 30; public function getNetworkAnalysisForLog( string $sourceIP, @@ -1648,16 +1645,11 @@ class Firewall extends Component $client = \Illuminate\Support\Facades\App::make(CoreRouterClient::class); - // Use cached states if available and not expired - $now = time(); - if ($this->cachedStates === null || - $this->cacheTimestamp === null || - ($now - $this->cacheTimestamp) > self::STATE_CACHE_TTL) { - $this->cachedStates = $client->listStates() ?? []; - $this->cacheTimestamp = $now; - } - - $allStates = $this->cachedStates ?? []; + // Use framework cache to persist states across Livewire requests + $cacheKey = 'proxmox_firewall_states_' . $this->server->id; + $allStates = Cache::remember($cacheKey, self::STATE_CACHE_TTL, function () use ($client) { + return $client->listStates() ?? []; + }); // Normalize search parameters once $normalizedSourceIP = strtolower(trim($sourceIP)); diff --git a/extensions/Servers/Proxmox/Livewire/Network.php b/extensions/Servers/Proxmox/Livewire/Network.php index bbd587393..08c389621 100644 --- a/extensions/Servers/Proxmox/Livewire/Network.php +++ b/extensions/Servers/Proxmox/Livewire/Network.php @@ -527,6 +527,12 @@ class Network extends Component return; } + // Check VM status - must be stopped to remove interfaces + if (!$this->status || $this->status->status !== 'stopped') { + $this->notify('VM must be stopped to remove network devices.', 'error'); + return; + } + try { $proxmox = new Proxmox; $proxmox->request( @@ -668,6 +674,12 @@ class Network extends Component return; } + // Check VM status - must be stopped to add interfaces + if (!$this->status || $this->status->status !== 'stopped') { + $this->notify('VM must be stopped to add network devices.', 'error'); + return; + } + try { // Get bridge and MTU from the lowest numbered network device $interfaceKeys = array_keys($this->interfaceConfigs); diff --git a/extensions/Servers/Proxmox/Livewire/Overview.php b/extensions/Servers/Proxmox/Livewire/Overview.php index a31faf9ab..ee96d4238 100644 --- a/extensions/Servers/Proxmox/Livewire/Overview.php +++ b/extensions/Servers/Proxmox/Livewire/Overview.php @@ -51,7 +51,7 @@ class Overview extends Component // Load notes from Proxmox VM description try { $proxmox = new Proxmox; - $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data']; + $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; $this->notes = $config['description'] ?? ''; } catch (\Exception $e) { $this->notes = ''; @@ -60,9 +60,9 @@ class Overview extends Component public function checkStatus() { - $currentStatus = $this->status->status; + $currentStatus = $this->status?->status; $this->loadVmStatus(); - if ($this->status->status != $currentStatus) { + if ($this->status?->status != $currentStatus) { $this->fastPolling = false; } } @@ -86,7 +86,7 @@ class Overview extends Component 'cf' => $aggregation === 'maximum' ? 'MAX' : 'AVERAGE', ]); - return $stats->json()['data']; + return $stats->json()['data'] ?? []; }); $this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $data); @@ -371,7 +371,7 @@ class Overview extends Component $this->hostnameError = ''; // Refresh status to get updated name - $this->status = (object) $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/current')->json()['data']; + $this->status = (object) ($proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $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'); @@ -495,7 +495,7 @@ class Overview extends Component // Reload notes from Proxmox try { $proxmox = new Proxmox; - $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data']; + $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; $this->notes = $config['description'] ?? ''; } catch (\Exception $e) { $this->notes = ''; diff --git a/extensions/Servers/Proxmox/Livewire/Reinstall.php b/extensions/Servers/Proxmox/Livewire/Reinstall.php index de327d557..5e53edee4 100644 --- a/extensions/Servers/Proxmox/Livewire/Reinstall.php +++ b/extensions/Servers/Proxmox/Livewire/Reinstall.php @@ -62,21 +62,21 @@ class Reinstall extends Component sleep(10); - $this->redirect('https://billing.virtnet.bond/servers/', true); + $this->redirect(route('proxmox.index'), true); } - public function render() -{ - // assuming Server has relation `node()` → Node model - $locationId = $this->server->node->location_id; + public function render() + { + // assuming Server has relation `node()` -> Node model + $locationId = $this->server->node->location_id; - $osList = \Paymenter\Extensions\Servers\Proxmox\Models\Location::findOrFail($locationId) - ->os() - ->get(); + $osList = \Paymenter\Extensions\Servers\Proxmox\Models\Location::findOrFail($locationId) + ->os() + ->get(); - return view('proxmox::reinstall', [ - 'osList' => $osList, - 'isProtected' => $this->isProtected, - ]); -} + return view('proxmox::reinstall', [ + 'osList' => $osList, + 'isProtected' => $this->isProtected, + ]); + } } diff --git a/extensions/Servers/Proxmox/Livewire/Server.php b/extensions/Servers/Proxmox/Livewire/Server.php index d778bcaf3..95fb5aa14 100644 --- a/extensions/Servers/Proxmox/Livewire/Server.php +++ b/extensions/Servers/Proxmox/Livewire/Server.php @@ -55,7 +55,7 @@ class Server extends Component public function getBandwidthThreshold95Property() { // Cache for 15 minutes to avoid recalculating for every server - return Cache::remember('bandwidth_threshold_95_' . Carbon::now()->format('Y-m'), 15, function () { + return Cache::remember('bandwidth_threshold_95_' . Carbon::now()->format('Y-m'), 900, function () { $startOfMonth = Carbon::now()->startOfMonth(); // Get all servers @@ -125,7 +125,7 @@ class Server extends Component $proxmox = new \Paymenter\Extensions\Servers\Proxmox\Proxmox; // Get current VM config - $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data']; + $config = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config')->json()['data'] ?? []; $updates = []; diff --git a/extensions/Servers/Proxmox/Livewire/Statistics.php b/extensions/Servers/Proxmox/Livewire/Statistics.php index df66a6baa..39cb86ca8 100644 --- a/extensions/Servers/Proxmox/Livewire/Statistics.php +++ b/extensions/Servers/Proxmox/Livewire/Statistics.php @@ -79,8 +79,8 @@ class Statistics extends Component 'error' => null, ]; - protected array $cachedServerIps = []; - protected array $serverIpCidrs = []; + public array $cachedServerIps = []; + public 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 @@ -152,14 +152,6 @@ class Statistics extends Component { parent::mount($server); - // Retry loading VM status if initial load failed (important for chart rendering) - $retries = 0; - while ($this->status === null && $retries < 3) { - usleep(100000); // 100ms delay between retries - $this->loadVmStatus(); - $retries++; - } - $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 @@ -864,11 +856,14 @@ class Statistics extends Component return $connections; } - protected function fetchGraphData(string $graphType, string $timeRange, string $aggregation): void + protected function fetchGraphData(string $graphType, string $timeRange, string $aggregation, bool $forceRefresh = false): void { $cacheKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation; try { + if ($forceRefresh) { + Cache::forget($cacheKey); + } // Reduced cache TTL from 60 to 30 seconds for fresher data and faster updates $data = Cache::remember($cacheKey, 30, function () use ($timeRange, $aggregation) { $proxmox = new Proxmox; @@ -2101,15 +2096,20 @@ class Statistics extends Component ]); } - public function formatBytes($bytes) + public function formatBytes(int|float $bytes): string { - if ($bytes == 0 || $bytes < 0) return '0 B'; + if ($bytes == 0) return '0 B'; + $prefix = ''; + if ($bytes < 0) { + $prefix = '-'; + $bytes = abs($bytes); + } $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]; + return $prefix . round($bytes / pow($k, $i), 2) . ' ' . $sizes[$i]; } public function getGraphData(string $type): array diff --git a/extensions/Servers/Proxmox/Livewire/Storage.php b/extensions/Servers/Proxmox/Livewire/Storage.php index 7569ab177..7349e52f3 100644 --- a/extensions/Servers/Proxmox/Livewire/Storage.php +++ b/extensions/Servers/Proxmox/Livewire/Storage.php @@ -296,8 +296,9 @@ class Storage extends Component data: ['vmid' => $this->server->vm_id] ); - if (isset($storageContent['data']) && is_array($storageContent['data'])) { - foreach ($storageContent['data'] as $item) { + $data = $storageContent->json()['data'] ?? []; + if (is_array($data)) { + foreach ($data as $item) { if (isset($item['volid']) && $item['volid'] === $volume) { // Size is in bytes if (isset($item['size'])) { @@ -553,7 +554,7 @@ class Storage extends Component '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config', 'put', [ - $driveKey => $this->storagePool . ':' . $this->newDriveSize . ',' . $driveConfig, + $driveKey => $this->storagePool . ':' . $this->newDriveSize . ($driveConfig ? ',' . $driveConfig : ''), ] ); @@ -769,7 +770,7 @@ class Storage extends Component $drive = $this->drives[$driveKey]; // Check VM status - must be stopped to detach - if ($this->status && $this->status->status !== 'stopped') { + if (!$this->status || $this->status->status !== 'stopped') { $this->notify('VM must be stopped to detach drives.', 'error'); return; } @@ -782,9 +783,11 @@ class Storage extends Component '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' ); + $configData = $config->json()['data'] ?? []; + // Find the next available unused slot $unusedIndex = 0; - while (isset($config['data']['unused' . $unusedIndex])) { + while (isset($configData['unused' . $unusedIndex])) { $unusedIndex++; } @@ -829,7 +832,7 @@ class Storage extends Component } // Check VM status - if ($this->status && $this->status->status !== 'stopped') { + if (!$this->status || $this->status->status !== 'stopped') { $this->notify('VM must be stopped to change device bus.', 'error'); return; } @@ -850,6 +853,7 @@ class Storage extends Component $config = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' ); + $configData = $config->json()['data'] ?? []; // Find the next available slot on the target bus by checking actual Proxmox config $maxIndex = $this->busTypes[$targetBus]['max'] ?? 4; @@ -858,7 +862,7 @@ class Storage extends Component for ($i = 0; $i < $maxIndex; $i++) { $slotKey = $targetBus . $i; // Check if this slot is occupied in the actual config - if (!isset($config['data'][$slotKey])) { + if (!isset($configData[$slotKey])) { $targetIndex = $i; break; } @@ -873,7 +877,7 @@ class Storage extends Component // Find the next available unused slot $unusedIndex = 0; - while (isset($config['data']['unused' . $unusedIndex])) { + while (isset($configData['unused' . $unusedIndex])) { $unusedIndex++; } @@ -988,7 +992,7 @@ class Storage extends Component $driveKey = $this->deletingDriveKey; // Check VM status - if ($this->status && $this->status->status !== 'stopped') { + if (!$this->status || $this->status->status !== 'stopped') { $this->notify('VM must be stopped to delete drives.', 'error'); return; } @@ -1068,7 +1072,7 @@ class Storage extends Component } // Check VM status - if ($this->status && $this->status->status !== 'stopped') { + if (!$this->status || $this->status->status !== 'stopped') { $this->notify('VM must be stopped to reattach drives.', 'error'); return; } @@ -1076,17 +1080,10 @@ class Storage extends Component $unusedDrive = $this->unusedDrives[$this->reattachingDriveKey]; $targetBus = $this->reattachTargetBus; - // Check if reattaching this drive would exceed the storage quota - // Calculate attached drives total (excluding the detached drive being reattached) - $attachedDrivesTotal = 0; - foreach ($this->drives as $drive) { - $attachedDrivesTotal += $drive['size'] ?? 0; - } - - // The drive being reattached is already counted in totalAllocatedStorage (which includes unused drives) - // So we just need to check if the total meets or exceeds the max - if ($this->totalAllocatedStorage >= $this->maxAllowedStorage && $this->maxAllowedStorage > 0) { - $this->notify('Storage quota reached. Total storage (' . number_format($this->totalAllocatedStorage, 1) . ' GB) meets or exceeds your plan allocation (' . number_format($this->maxAllowedStorage, 1) . ' GB). Please upgrade your service or delete drives to allow this configuration.', 'error'); + // 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) { + $this->notify('Storage quota exceeded. Total storage (' . number_format($this->totalAllocatedStorage, 1) . ' GB) exceeds your plan allocation (' . number_format($this->maxAllowedStorage, 1) . ' GB). Please upgrade your service or delete drives to allow this configuration.', 'error'); return; } @@ -1097,6 +1094,7 @@ class Storage extends Component $config = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' ); + $configData = $config->json()['data'] ?? []; // Find the next available slot on the target bus by checking actual Proxmox config $maxIndex = $this->busTypes[$targetBus]['max'] ?? 4; @@ -1105,7 +1103,7 @@ class Storage extends Component for ($i = 0; $i < $maxIndex; $i++) { $slotKey = $targetBus . $i; // Check if this slot is occupied in the actual config - if (!isset($config['data'][$slotKey])) { + if (!isset($configData[$slotKey])) { $targetIndex = $i; break; } @@ -1118,7 +1116,7 @@ class Storage extends Component $targetKey = $targetBus . $targetIndex; - $unusedValue = $config['data'][$this->reattachingDriveKey] ?? null; + $unusedValue = $configData[$this->reattachingDriveKey] ?? null; if (!$unusedValue) { $this->notify('Unused drive no longer exists. Please refresh the page.', 'error'); $this->loadStorageConfiguration(false); @@ -1184,7 +1182,7 @@ class Storage extends Component } // Check VM status - if ($this->status && $this->status->status !== 'stopped') { + if (!$this->status || $this->status->status !== 'stopped') { $this->notify('VM must be stopped to delete drives.', 'error'); return; } @@ -1217,7 +1215,7 @@ class Storage extends Component } // Check VM status - if ($this->status && $this->status->status !== 'stopped') { + if (!$this->status || $this->status->status !== 'stopped') { $this->notify('VM must be stopped to change SCSI controller type.', 'error'); return; } diff --git a/extensions/Servers/Proxmox/Livewire/Subdomain.php b/extensions/Servers/Proxmox/Livewire/Subdomain.php index 59816eb2c..cf825bdf0 100644 --- a/extensions/Servers/Proxmox/Livewire/Subdomain.php +++ b/extensions/Servers/Proxmox/Livewire/Subdomain.php @@ -144,7 +144,7 @@ class Subdomain extends Component if (preg_match('/^fc[0-9a-f]{1,2}:/i', $ip) || preg_match('/^fd[0-9a-f]{1,2}:/i', $ip)) { return false; } - if (strpos($ip, '::ffff:') === 0 || strpos($ip, '::ffff:') !== false) { + if (strpos($ip, '::ffff:') === 0) { return false; } return true; @@ -223,7 +223,7 @@ class Subdomain extends Component { $this->validate([ 'newSubdomain' => 'required|string|regex:/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/i', - 'selectedIpId' => 'required|exists:ext_proxmox_ip_addresses,id', + 'selectedIpId' => ['required', \Illuminate\Validation\Rule::exists('ext_proxmox_ip_addresses', 'id')->where('server_id', $this->server->id)], ], [ 'newSubdomain.regex' => 'Invalid subdomain format. Use only letters, numbers, and hyphens.', 'selectedIpId.required' => 'Please select an IP address.', @@ -285,12 +285,6 @@ class Subdomain extends Component $this->notify('This subdomain already exists for an IPv6 address on this server. Please choose a different name.', 'error'); return; } - - // Check if we already have both IPv4 and IPv6 for this subdomain - if ($existingIpv4Count > 0 && $existingIpv6Count > 0) { - $this->notify('This subdomain already exists for both IPv4 and IPv6 addresses on this server. Please choose a different name.', 'error'); - return; - } // Create the subdomain via Technitium DNS API $dns = new TechnitiumDns; @@ -532,9 +526,6 @@ class Subdomain extends Component $dns = new TechnitiumDns; if ($dns->deleteRecord($this->selectedZone, $name, $type, $ipAddress)) { - // Update database with new record list - $this->updateSubdomainRecords(); - $this->notify('DNS record deleted successfully!', 'success'); $this->loadZoneRecords(); } else { diff --git a/extensions/Servers/Proxmox/Livewire/System.php b/extensions/Servers/Proxmox/Livewire/System.php index f589cc683..84077c4ba 100644 --- a/extensions/Servers/Proxmox/Livewire/System.php +++ b/extensions/Servers/Proxmox/Livewire/System.php @@ -387,8 +387,8 @@ class System extends Component } return collect(explode(';', $clean)) - ->map(fn ($flag) => ltrim($flag, '+-')) - ->filter() + ->map(fn ($flag) => ltrim($flag, '+')) + ->filter(fn ($flag) => $flag !== '' && !str_starts_with($flag, '-')) ->unique() ->values() ->toArray(); @@ -418,6 +418,12 @@ class System extends Component return; } + // Check VM status - must be stopped to remove EFI/TPM devices + if (!$this->status || $this->status->status !== 'stopped') { + $this->notify('VM must be stopped to remove system devices.', 'error'); + return; + } + // Get the device definition before removing it $deviceDefinition = null; if (str_starts_with($deviceKey, 'efidisk') && isset($this->efiDisks[$deviceKey])) { @@ -547,6 +553,12 @@ class System extends Component return; } + // Check VM status - must be stopped to add EFI disk + if (!$this->status || $this->status->status !== 'stopped') { + $this->notify('VM must be stopped to add an EFI disk.', 'error'); + return; + } + $slot = 'efidisk0'; $volumeName = 'vm-' . $this->server->vm_id . '-efidisk0'; @@ -589,6 +601,12 @@ class System extends Component return; } + // Check VM status - must be stopped to add TPM device + if (!$this->status || $this->status->status !== 'stopped') { + $this->notify('VM must be stopped to add a TPM device.', 'error'); + return; + } + $version = trim($this->newTpmVersion); $slot = 'tpmstate0'; diff --git a/extensions/Servers/Proxmox/Models/Location.php b/extensions/Servers/Proxmox/Models/Location.php index 55239962e..b18bb8b77 100644 --- a/extensions/Servers/Proxmox/Models/Location.php +++ b/extensions/Servers/Proxmox/Models/Location.php @@ -11,6 +11,7 @@ class Location extends Model protected $fillable = [ // Shows to customer 'name', + 'server_placement', ]; public function nodes() diff --git a/extensions/Servers/Proxmox/Models/Node.php b/extensions/Servers/Proxmox/Models/Node.php index 6e42794e2..c1cf9f431 100644 --- a/extensions/Servers/Proxmox/Models/Node.php +++ b/extensions/Servers/Proxmox/Models/Node.php @@ -11,6 +11,7 @@ class Node extends Model protected $fillable = [ // Shows to customer 'name', + 'notes', 'memory', 'disk', 'cpu', @@ -51,9 +52,9 @@ class Node extends Model $usedDisk = 0; $usedCpu = 0; foreach ($servers as $server) { - $usedMemory += (int) $server->service->product->settings()->where('key', 'memory')->first()->value ?? 0; - $usedDisk += (int) $server->service->product->settings()->where('key', 'disk')->first()->value ?? 0; - $usedCpu += (int) $server->service->product->settings()->where('key', 'cpu')->first()->value ?? 0; + $usedMemory += (int) ($server->service->product->settings()->where('key', 'memory')->first()?->value ?? 0); + $usedDisk += (int) ($server->service->product->settings()->where('key', 'disk')->first()?->value ?? 0); + $usedCpu += (int) ($server->service->product->settings()->where('key', 'cpu')->first()?->value ?? 0); } return [ diff --git a/extensions/Servers/Proxmox/Models/OS.php b/extensions/Servers/Proxmox/Models/OS.php index cc8c2f06b..ffd1a3b47 100644 --- a/extensions/Servers/Proxmox/Models/OS.php +++ b/extensions/Servers/Proxmox/Models/OS.php @@ -12,5 +12,6 @@ class OS extends Model // Shows to customer 'name', 'vm_id', + 'location_id', ]; } diff --git a/extensions/Servers/Proxmox/Proxmox.php b/extensions/Servers/Proxmox/Proxmox.php index 4e4460b66..5181fdbae 100644 --- a/extensions/Servers/Proxmox/Proxmox.php +++ b/extensions/Servers/Proxmox/Proxmox.php @@ -143,7 +143,7 @@ class Proxmox extends ClassServer $resourcePool = $this->request('/pools'); $poolList = []; - foreach ($resourcePool->json()['data'] as $pool) { + foreach ($resourcePool->json()['data'] ?? [] as $pool) { $poolList[] = [ 'label' => $pool['poolid'], 'value' => $pool['poolid'], @@ -326,7 +326,7 @@ class Proxmox extends ClassServer ], [ 'label' => 'Write Back', - 'value' => 'write back', + 'value' => 'writeback', ], [ 'label' => 'Write Back (unsafe)', @@ -411,19 +411,29 @@ class Proxmox extends ClassServer $response = $this->request($url, $method, $data); // Check if the task is running - $task = $response->json()['data']; + $task = $response->json()['data'] ?? null; sleep(1); // Give Proxmox some time to start the task + if (!$task) { + return 'error'; + } + return $this->waitForTask($task, $node); } public function waitForTask($task, $node) { - // Check if the task is running - $status = $this->request('/nodes/' . $node . '/tasks/' . $task . '/status')->json()['data']['status']; - while ($status == 'running') { + // Check if the task is running (with max 300 iterations / 5 minute timeout) + $status = $this->request('/nodes/' . $node . '/tasks/' . $task . '/status')->json()['data']['status'] ?? null; + $iterations = 0; + while ($status == 'running' && $iterations < 300) { sleep(1); - $status = $this->request('/nodes/' . $node . '/tasks/' . $task . '/status')->json()['data']['status']; + $status = $this->request('/nodes/' . $node . '/tasks/' . $task . '/status')->json()['data']['status'] ?? null; + $iterations++; + } + + if ($iterations >= 300) { + \Log::warning('Proxmox task timed out after 300 seconds', ['task' => $task, 'node' => $node]); } return $status; @@ -456,7 +466,7 @@ class Proxmox extends ClassServer $server->save(); // Create the VM - $this->requestAndWait('/nodes/' . $server->node->name . '/qemu/' . $server->os->vm_id . '/clone', 'post', [ + $cloneResult = $this->requestAndWait('/nodes/' . $server->node->name . '/qemu/' . $server->os->vm_id . '/clone', 'post', [ 'newid' => $server->vm_id, 'name' => $server->hostname, 'target' => $server->node->name, @@ -465,6 +475,18 @@ class Proxmox extends ClassServer 'pool' => $settings['pool'], ], $server->node->name); + if ($cloneResult !== 'stopped' && $cloneResult !== 'OK') { + \Log::error('Proxmox VM clone failed for server ' . $server->id, [ + 'result' => $cloneResult, + 'vm_id' => $server->vm_id, + 'node' => $server->node->name, + ]); + $server->status = 'error'; + $server->save(); + + return; + } + $this->syncServerSettings($server, $settings, $password); // Start the server @@ -492,7 +514,7 @@ class Proxmox extends ClassServer $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config', 'put', $data); // Get current disk type - $disk = $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config')->json()['data']; + $disk = $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config')->json()['data'] ?? []; // As the disk type can be different, we need to check if it is scsi or virtio or sata or ide $diskType = 'scsi0'; if (isset($disk['virtio0'])) { @@ -518,7 +540,9 @@ class Proxmox extends ClassServer $network .= ',rate=' . $settings['ratelimit']; } $config['net0'] = $network; - $config['ipconfig0'] = 'ip=' . $ipv4->ip . '/24' . ',gw=' . $ipv4->gateway . ',ip6=' . $ipv6->ip . '/64' . ',gw6=' . $ipv6->gateway; + $ipv4Cidr = $ipv4->netmask ?: '24'; + $ipv6Cidr = $ipv6->netmask ?: '64'; + $config['ipconfig0'] = 'ip=' . $ipv4->ip . '/' . $ipv4Cidr . ',gw=' . $ipv4->gateway . ',ip6=' . $ipv6->ip . '/' . $ipv6Cidr . ',gw6=' . $ipv6->gateway; } else { if ($ipv4) { $network = $settings['model'] . '=' . ($ipv4->mac_address ? $ipv4->mac_address : $this->generateMacAddress()) . ',bridge=' . $ipv4->ipPool->network_interface; @@ -527,7 +551,8 @@ class Proxmox extends ClassServer $network .= ',rate=' . $settings['ratelimit']; } $config['net0'] = $network; - $config['ipconfig0'] = 'ip=' . $ipv4->ip . '/24' . ',gw=' . $ipv4->gateway; + $ipv4Cidr = $ipv4->netmask ?: '24'; + $config['ipconfig0'] = 'ip=' . $ipv4->ip . '/' . $ipv4Cidr . ',gw=' . $ipv4->gateway; } if ($ipv6) { $network = $settings['model'] . '=' . ($ipv6->mac_address ? $ipv6->mac_address : $this->generateMacAddress()) . ',bridge=' . $ipv6->ipPool->network_interface; @@ -536,7 +561,8 @@ class Proxmox extends ClassServer $network .= ',rate=' . $settings['ratelimit']; } $config['net1'] = $network; - $config['ipconfig1'] = 'ip6=' . $ipv6->ip . '/64' . ',gw6=' . $ipv6->gateway; + $ipv6Cidr = $ipv6->netmask ?: '64'; + $config['ipconfig1'] = 'ip6=' . $ipv6->ip . '/' . $ipv6Cidr . ',gw6=' . $ipv6->gateway; } } @@ -724,7 +750,11 @@ class Proxmox extends ClassServer ])->post(rtrim($this->config('host'), '/') . '/api2/json/access/ticket', [ 'username' => 'PAYMENTER_VNC_' . $server->id . '@pve', 'password' => $password, - ])->throw()->json()['data']; + ])->throw()->json()['data'] ?? []; + + if (empty($response['ticket']) || empty($response['CSRFPreventionToken'])) { + throw new \RuntimeException('Failed to obtain Proxmox authentication ticket.'); + } $domain = rtrim($this->config('host'), '/'); // Remove port and http @@ -739,7 +769,7 @@ class Proxmox extends ClassServer 'verify' => $this->config('verify_ssl') ? true : false, ])->post(rtrim($this->config('host'), '/') . '/api2/json/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/vncproxy', [ 'websocket' => 1, - ])->json()['data']; + ])->json()['data'] ?? []; // Current $paymenterDomain = rtrim(config('settings.app_url'), '/'); @@ -750,7 +780,7 @@ class Proxmox extends ClassServer if (count($parts) >= 2) { $paymenterDomain = $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1]; } - setcookie('PVEAuthCookie', $response['ticket'], 0, '/', $paymenterDomain, false, true); + setcookie('PVEAuthCookie', $response['ticket'], 0, '/', $paymenterDomain, true, true); return rtrim($this->config('host'), '/') . '/?console=kvm' . @@ -770,7 +800,11 @@ class Proxmox extends ClassServer ])->post(rtrim($this->config('host'), '/') . '/api2/json/access/ticket', [ 'username' => 'PAYMENTER_VNC_' . $server->id . '@pve', 'password' => $password, - ])->throw()->json()['data']; + ])->throw()->json()['data'] ?? []; + + if (empty($response['ticket'])) { + throw new \RuntimeException('Failed to obtain Proxmox authentication ticket.'); + } $domain = rtrim($this->config('host'), '/'); // Remove port and http @@ -786,7 +820,7 @@ class Proxmox extends ClassServer if (count($parts) >= 2) { $paymenterDomain = $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1]; } - setcookie('PVEAuthCookie', $response['ticket'], 0, '/', $paymenterDomain, false, true); + setcookie('PVEAuthCookie', $response['ticket'], 0, '/', $paymenterDomain, true, true); return rtrim($this->config('host'), '/') . '/?console=kvm' . @@ -888,6 +922,10 @@ class Proxmox extends ClassServer // Update server status $server = Server::where('service_id', $service->id)->first(); + if (!$server) { + return false; + } + // Stop server $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/stop', 'post', [ 'vmid' => $server->vm_id, @@ -895,6 +933,8 @@ class Proxmox extends ClassServer $server->status = 'suspended'; $server->save(); + + return true; } /** @@ -909,8 +949,14 @@ class Proxmox extends ClassServer // Update server status $server = Server::where('service_id', $service->id)->first(); + if (!$server) { + return false; + } + $server->status = 'active'; $server->save(); + + return true; } /** @@ -927,11 +973,10 @@ class Proxmox extends ClassServer } $server = Server::where('service_id', $service->id)->first(); - // Kill the server - $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/stop', 'post', [ + // 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, - ]); - sleep(3); + ], $server->node->name); // Delete proxmox server $this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id, 'delete'); // Delete server diff --git a/extensions/Servers/Proxmox/Services/CoreRouterClient.php b/extensions/Servers/Proxmox/Services/CoreRouterClient.php index 19910ade4..1ee11d094 100644 --- a/extensions/Servers/Proxmox/Services/CoreRouterClient.php +++ b/extensions/Servers/Proxmox/Services/CoreRouterClient.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Http; class CoreRouterClient { - public function listStates(int $limit = null): array + public function listStates(?int $limit = null): array { $queryParams = []; diff --git a/extensions/Servers/Proxmox/TechnitiumDns.php b/extensions/Servers/Proxmox/TechnitiumDns.php index fa0260db7..e89f54163 100644 --- a/extensions/Servers/Proxmox/TechnitiumDns.php +++ b/extensions/Servers/Proxmox/TechnitiumDns.php @@ -114,7 +114,10 @@ class TechnitiumDns $json = $response->json(); if ($json['status'] !== 'ok') { - return false; + return [ + 'success' => false, + 'message' => 'Failed to create DNS zone.', + ]; } // Add A or AAAA record for the subdomain based on IP version @@ -132,7 +135,10 @@ class TechnitiumDns $recordJson = $recordResponse->json(); if ($recordJson['status'] !== 'ok') { - return false; + return [ + 'success' => false, + 'message' => 'Failed to add DNS record.', + ]; } // Add wildcard record (*.subdomain.dns.virtnet.host) diff --git a/extensions/Servers/Proxmox/database/migrations/2025_12_05_150000_add_user_set_icon_to_ext_proxmox_servers.php b/extensions/Servers/Proxmox/database/migrations/2025_12_05_150000_add_user_set_icon_to_ext_proxmox_servers.php new file mode 100644 index 000000000..fae2939fb --- /dev/null +++ b/extensions/Servers/Proxmox/database/migrations/2025_12_05_150000_add_user_set_icon_to_ext_proxmox_servers.php @@ -0,0 +1,33 @@ +string('user_set_icon', 64)->nullable()->after('password'); + } + if (!Schema::hasColumn('ext_proxmox_servers', 'user_set_icon_color')) { + $table->string('user_set_icon_color', 32)->nullable()->after('user_set_icon'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ext_proxmox_servers', function (Blueprint $table) { + $table->dropColumn(['user_set_icon', 'user_set_icon_color']); + }); + } +}; diff --git a/extensions/Servers/Proxmox/database/migrations/2026_02_22_000001_add_notes_to_ext_proxmox_nodes_table.php b/extensions/Servers/Proxmox/database/migrations/2026_02_22_000001_add_notes_to_ext_proxmox_nodes_table.php new file mode 100644 index 000000000..d12084e2a --- /dev/null +++ b/extensions/Servers/Proxmox/database/migrations/2026_02_22_000001_add_notes_to_ext_proxmox_nodes_table.php @@ -0,0 +1,30 @@ +string('notes')->nullable()->after('name'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ext_proxmox_nodes', function (Blueprint $table) { + $table->dropColumn('notes'); + }); + } +}; diff --git a/extensions/Servers/Proxmox/routes.php b/extensions/Servers/Proxmox/routes.php index ed026088c..fc8bcfaf5 100644 --- a/extensions/Servers/Proxmox/routes.php +++ b/extensions/Servers/Proxmox/routes.php @@ -44,7 +44,10 @@ Route::group([ // Popup console routes (minimal layout for popup windows) Route::get('{server}/vnc-popup', function (Server $server) { - if (!$server->node->vnc_enabled) { + if ((int) $server->user_id !== (int) auth()->id()) { + abort(403); + } + if (!$server->node?->vnc_enabled) { abort(403, 'VNC is not enabled on this server node.'); } $proxmox = new Proxmox; @@ -55,7 +58,10 @@ Route::group([ })->name('server.vnc-popup'); Route::get('{server}/xterm-popup', function (Server $server) { - if (!$server->node->vnc_enabled) { + if ((int) $server->user_id !== (int) auth()->id()) { + abort(403); + } + if (!$server->node?->vnc_enabled) { abort(403, 'Console access is not enabled on this server node.'); } $proxmox = new Proxmox; diff --git a/extensions/Servers/Proxmox/views/backups.blade.php b/extensions/Servers/Proxmox/views/backups.blade.php index 1df5dd8d8..7129c154c 100644 --- a/extensions/Servers/Proxmox/views/backups.blade.php +++ b/extensions/Servers/Proxmox/views/backups.blade.php @@ -87,7 +87,7 @@
@forelse($backups as $backup) @php $backup = (object) $backup; @endphp -{{ $backup->notes ?: '—' }}