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
304 lines
10 KiB
PHP
304 lines
10 KiB
PHP
<?php
|
|
|
|
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
|
|
|
|
use App\Helpers\ExtensionHelper;
|
|
use App\Livewire\Component as BaseComponent;
|
|
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
|
|
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
|
|
|
|
class Component extends BaseComponent
|
|
{
|
|
/**
|
|
* The server instance.
|
|
*/
|
|
public Server $server;
|
|
|
|
/**
|
|
* Latest VM status snapshot shared with all Proxmox views.
|
|
*/
|
|
public ?object $status = null;
|
|
|
|
/**
|
|
* Controls dynamic polling interval (shared with layout header).
|
|
*/
|
|
public bool $fastPolling = false;
|
|
|
|
/**
|
|
* Cached Proxmox storage content lookups keyed by storage name.
|
|
*
|
|
* @var array<string, array<int, array<string, mixed>>>
|
|
*/
|
|
protected array $storageContentCache = [];
|
|
|
|
/**
|
|
* Mount the component with the server.
|
|
*/
|
|
public function mount(Server $server): void
|
|
{
|
|
$this->server = $server;
|
|
if ((int) $server->user_id !== (int) auth()->id()) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->loadVmStatus();
|
|
}
|
|
|
|
/**
|
|
* Perform a VM lifecycle action (start/stop/etc).
|
|
*/
|
|
public function do(string $action): void
|
|
{
|
|
if (!in_array($action, ['start', 'shutdown', 'reboot', 'stop', 'reset'], true)) {
|
|
$this->notify('Invalid action', 'error');
|
|
|
|
return;
|
|
}
|
|
|
|
// Check storage quota before starting the VM (KVM only)
|
|
if ($action === 'start' && !$this->server->node->is_lxc) {
|
|
$quotaCheck = $this->checkStorageQuota();
|
|
if ($quotaCheck !== true) {
|
|
$this->notify($quotaCheck, 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$proxmox = new Proxmox;
|
|
$vmType = $this->server->node->vmType();
|
|
$response = $proxmox->request(
|
|
'/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/status/' . $action,
|
|
'post',
|
|
['vmid' => $this->server->vm_id]
|
|
);
|
|
|
|
$this->notify('Action performed successfully', 'success');
|
|
if (property_exists($this, 'fastPolling')) {
|
|
$this->fastPolling = true;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
$this->notify('Action failed. Please try again in a moment.', 'error');
|
|
} finally {
|
|
$this->loadVmStatus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if storage quota allows VM to start.
|
|
* Returns true if OK, or an error message string if over quota.
|
|
*/
|
|
protected function checkStorageQuota(): bool|string
|
|
{
|
|
try {
|
|
// Get max allowed storage from package
|
|
$settings = array_merge(
|
|
ExtensionHelper::settingsToArray($this->server->service->product->settings),
|
|
ExtensionHelper::getServiceProperties($this->server->service)
|
|
);
|
|
$maxAllowedStorage = isset($settings['disk']) ? (float) $settings['disk'] : 0;
|
|
|
|
// If no limit is set, allow start
|
|
if ($maxAllowedStorage <= 0) {
|
|
return true;
|
|
}
|
|
|
|
// Get VM config to calculate total storage
|
|
$proxmox = new Proxmox;
|
|
$vmType = $this->server->node->vmType();
|
|
$configResponse = $proxmox->request(
|
|
'/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/config'
|
|
);
|
|
|
|
// Note: request() calls ->throw(), so response is always successful here
|
|
$config = $configResponse->json()['data'] ?? [];
|
|
$totalStorage = $this->calculateTotalStorage($config);
|
|
|
|
if ($totalStorage > $maxAllowedStorage) {
|
|
return 'Cannot start VM: Storage quota exceeded. Your server has ' . number_format($totalStorage, 1) . ' GB of storage configured, but your plan only allows ' . number_format($maxAllowedStorage, 1) . ' GB. Please configure your server storage within your package limits or upgrade your service to allow this configuration.';
|
|
}
|
|
|
|
return true;
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
return 'Unable to validate storage quota at this time. Please try again in a moment.';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate total storage from VM config (including unused/detached drives).
|
|
* Uses the same calculation method as the Storage page.
|
|
*/
|
|
protected function calculateTotalStorage(array $config): float
|
|
{
|
|
$totalSize = 0;
|
|
|
|
foreach ($config as $key => $value) {
|
|
// Check for attached drives (ide, sata, scsi, virtio)
|
|
if (preg_match('/^(ide|sata|scsi|virtio)(\d+)$/', $key)) {
|
|
// Skip excluded disks (cloud-init, EFI, TPM)
|
|
if ($this->isExcludedDisk($value)) {
|
|
continue;
|
|
}
|
|
// Skip CD-ROM drives
|
|
if (str_contains($value, 'media=cdrom')) {
|
|
continue;
|
|
}
|
|
$totalSize += $this->extractDiskSize($value);
|
|
}
|
|
// LXC root filesystem and mount points
|
|
elseif ($key === 'rootfs' || preg_match('/^mp(\d+)$/', $key)) {
|
|
if ($this->isExcludedDisk($value)) {
|
|
continue;
|
|
}
|
|
$totalSize += $this->extractDiskSize($value);
|
|
}
|
|
// Check for unused/detached drives
|
|
elseif (preg_match('/^unused(\d+)$/', $key)) {
|
|
if ($this->isExcludedDisk($value)) {
|
|
continue;
|
|
}
|
|
$totalSize += $this->extractUnusedDiskSize($value);
|
|
}
|
|
}
|
|
|
|
return $totalSize;
|
|
}
|
|
|
|
/**
|
|
* Check if a disk should be excluded (cloud-init, EFI, TPM).
|
|
* Uses regex patterns matching the Storage page implementation.
|
|
*/
|
|
protected function isExcludedDisk(string $value): bool
|
|
{
|
|
$excludePatterns = [
|
|
'cloudinit',
|
|
'vm-\d+-cloudinit',
|
|
'efidisk',
|
|
'tpmstate',
|
|
];
|
|
|
|
foreach ($excludePatterns as $pattern) {
|
|
if (preg_match('/' . $pattern . '/i', $value)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Extract disk size in GB from a disk configuration string.
|
|
* Matches the Storage page parseDiskString logic.
|
|
*/
|
|
protected function extractDiskSize(string $value): float
|
|
{
|
|
// Example: cephpool:vm-100-disk-0,size=32G,cache=none,ssd=1
|
|
$parts = explode(',', $value);
|
|
|
|
foreach ($parts as $part) {
|
|
if (preg_match('/^size=(\d+)([KMGT]?)$/i', $part, $sizeMatch)) {
|
|
$size = (float) $sizeMatch[1];
|
|
$unit = strtoupper($sizeMatch[2] ?? 'G');
|
|
|
|
switch ($unit) {
|
|
case 'K':
|
|
return $size / (1024 * 1024);
|
|
case 'M':
|
|
return $size / 1024;
|
|
case 'G':
|
|
case '':
|
|
return $size;
|
|
case 'T':
|
|
return $size * 1024;
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Extract size from unused disk by querying Proxmox storage content.
|
|
* Matches the Storage page parseUnusedDiskString logic.
|
|
*/
|
|
protected function extractUnusedDiskSize(string $value): float
|
|
{
|
|
// Unused disks typically just have the volume reference
|
|
// Example: cephpool:vm-100-disk-1
|
|
$volume = trim($value);
|
|
|
|
// Extract storage name from volume (format: storage:volume-name)
|
|
$volumeParts = explode(':', $volume, 2);
|
|
$storageName = trim($volumeParts[0] ?? '');
|
|
if ($storageName === '') {
|
|
return 0;
|
|
}
|
|
|
|
$storageRows = $this->getStorageContentRows($storageName);
|
|
foreach ($storageRows as $item) {
|
|
if (isset($item['volid']) && $item['volid'] === $volume && isset($item['size'])) {
|
|
return round(((float) $item['size']) / (1024 * 1024 * 1024), 2);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Fetch and memoize storage contents for the active VM.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
protected function getStorageContentRows(string $storageName): array
|
|
{
|
|
if (array_key_exists($storageName, $this->storageContentCache)) {
|
|
return $this->storageContentCache[$storageName];
|
|
}
|
|
|
|
try {
|
|
$proxmox = new Proxmox;
|
|
$storageContent = $proxmox->request(
|
|
'/nodes/' . $this->server->node->name . '/storage/' . $storageName . '/content',
|
|
data: ['vmid' => $this->server->vm_id]
|
|
);
|
|
$rows = $storageContent->json()['data'] ?? [];
|
|
$this->storageContentCache[$storageName] = is_array($rows) ? $rows : [];
|
|
} catch (\Throwable $e) {
|
|
$this->storageContentCache[$storageName] = [];
|
|
}
|
|
|
|
return $this->storageContentCache[$storageName];
|
|
}
|
|
|
|
/**
|
|
* Refresh the cached VM status from Proxmox.
|
|
*/
|
|
public function loadVmStatus(): void
|
|
{
|
|
try {
|
|
$proxmox = new Proxmox;
|
|
$vmType = $this->server->node->vmType();
|
|
$response = $proxmox->request(
|
|
'/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $this->server->vm_id . '/status/current'
|
|
);
|
|
|
|
$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);
|
|
$this->status = null;
|
|
} catch (\Throwable $e) {
|
|
\Log::error('Exception loading VM status for server ' . $this->server->id, [
|
|
'server_id' => $this->server->id,
|
|
'vm_id' => $this->server->vm_id,
|
|
'node' => $this->server->node->name ?? 'unknown',
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$this->status = null;
|
|
}
|
|
}
|
|
}
|