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
1310 lines
53 KiB
PHP
1310 lines
53 KiB
PHP
<?php
|
|
|
|
namespace Paymenter\Extensions\Servers\Proxmox;
|
|
|
|
use App\Classes\Extension\Server as ClassServer;
|
|
use App\Models\Product;
|
|
use App\Models\Service;
|
|
use Exception;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\View;
|
|
use Illuminate\Support\HtmlString;
|
|
use Illuminate\Support\Str;
|
|
use Paymenter\Extensions\Servers\Proxmox\Admin\Resources\LocationResource;
|
|
use Paymenter\Extensions\Servers\Proxmox\Admin\Resources\NodeResource;
|
|
use Paymenter\Extensions\Servers\Proxmox\Jobs\ReinstallJob;
|
|
use Paymenter\Extensions\Servers\Proxmox\Models\IPPool;
|
|
use Paymenter\Extensions\Servers\Proxmox\Models\Location;
|
|
use Paymenter\Extensions\Servers\Proxmox\Models\Node;
|
|
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
|
|
|
|
class Proxmox extends ClassServer
|
|
{
|
|
private static bool $migrationsEnsured = false;
|
|
private const CONSOLE_CREDENTIAL_TTL_SECONDS = 1800;
|
|
|
|
public function enabled()
|
|
{
|
|
if (self::$migrationsEnsured) {
|
|
return;
|
|
}
|
|
|
|
Artisan::call('migrate', ['--path' => 'extensions/Servers/Proxmox/database/migrations', '--force' => true]);
|
|
self::$migrationsEnsured = true;
|
|
}
|
|
|
|
public function boot()
|
|
{
|
|
include __DIR__ . '/routes.php';
|
|
|
|
// Register views
|
|
View::addNamespace('proxmox', __DIR__ . '/views');
|
|
|
|
\Livewire\Livewire::component('proxmox.overview', \Paymenter\Extensions\Servers\Proxmox\Livewire\Overview::class);
|
|
\Livewire\Livewire::component('proxmox.firewall', \Paymenter\Extensions\Servers\Proxmox\Livewire\Firewall::class);
|
|
\Livewire\Livewire::component('proxmox.network', \Paymenter\Extensions\Servers\Proxmox\Livewire\Network::class);
|
|
\Livewire\Livewire::component('proxmox.index', \Paymenter\Extensions\Servers\Proxmox\Livewire\Index::class);
|
|
\Livewire\Livewire::component('proxmox.server', \Paymenter\Extensions\Servers\Proxmox\Livewire\Server::class);
|
|
\Livewire\Livewire::component('proxmox.boot-order', \Paymenter\Extensions\Servers\Proxmox\Livewire\BootOrder::class);
|
|
\Livewire\Livewire::component('proxmox.display', \Paymenter\Extensions\Servers\Proxmox\Livewire\Display::class);
|
|
\Livewire\Livewire::component('proxmox.system', \Paymenter\Extensions\Servers\Proxmox\Livewire\System::class);
|
|
\Livewire\Livewire::component('proxmox.storage', \Paymenter\Extensions\Servers\Proxmox\Livewire\Storage::class);
|
|
\Livewire\Livewire::component('proxmox.cloud-init', \Paymenter\Extensions\Servers\Proxmox\Livewire\CloudInit::class);
|
|
\Livewire\Livewire::component('proxmox.subdomain', \Paymenter\Extensions\Servers\Proxmox\Livewire\Subdomain::class);
|
|
\Livewire\Livewire::component('proxmox.reinstall', \Paymenter\Extensions\Servers\Proxmox\Livewire\Reinstall::class);
|
|
\Livewire\Livewire::component('proxmox.backups', \Paymenter\Extensions\Servers\Proxmox\Livewire\Backups\Index::class);
|
|
\Livewire\Livewire::component('proxmox.backups.create', \Paymenter\Extensions\Servers\Proxmox\Livewire\Backups\Create::class);
|
|
\Livewire\Livewire::component('proxmox.vnc', \Paymenter\Extensions\Servers\Proxmox\Livewire\VNC::class);
|
|
\Livewire\Livewire::component('proxmox.xterm', \Paymenter\Extensions\Servers\Proxmox\Livewire\XTerm::class);
|
|
\Livewire\Livewire::component('proxmox.help', \Paymenter\Extensions\Servers\Proxmox\Livewire\Help::class);
|
|
\Livewire\Livewire::component('proxmox.statistics', \Paymenter\Extensions\Servers\Proxmox\Livewire\StatisticsRedirect::class);
|
|
|
|
Event::listen('navigation.dashboard', function () {
|
|
if (Server::where('user_id', auth()->id())->count() == 0) {
|
|
return;
|
|
}
|
|
|
|
return [
|
|
'name' => 'Servers',
|
|
'route' => 'proxmox.index',
|
|
'icon' => 'ri-server',
|
|
'separator' => true,
|
|
'priority' => 20,
|
|
];
|
|
});
|
|
}
|
|
|
|
public function request($url, $method = 'get', $data = [], ?int $timeoutSeconds = null, ?int $connectTimeoutSeconds = null)
|
|
{
|
|
$url = rtrim($this->config('host'), '/') . '/api2/json' . $url;
|
|
$timeout = $timeoutSeconds ?? 15;
|
|
$connectTimeout = $connectTimeoutSeconds ?? 5;
|
|
|
|
$response = Http::withHeaders([
|
|
'Authorization' => 'PVEAPIToken=' . $this->config('user') . '=' . $this->config('token'),
|
|
'Accept' => 'application/json',
|
|
'Content-Type' => 'application/json',
|
|
])->withOptions([
|
|
'verify' => $this->config('verify_ssl') ? true : false,
|
|
])->timeout($timeout)->connectTimeout($connectTimeout)->$method($url, $data)->throw();
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Get all the configuration for the extension
|
|
*
|
|
* @param array $values
|
|
*/
|
|
public function getConfig($values = []): array
|
|
{
|
|
return [
|
|
[
|
|
'name' => 'host',
|
|
'label' => 'Host',
|
|
'type' => 'text',
|
|
'required' => true,
|
|
'description' => 'Full URL of the Proxmox server (e.g. https://proxmox.paymenter.org:8006)',
|
|
],
|
|
[
|
|
'name' => 'user',
|
|
'label' => 'Username',
|
|
'type' => 'text',
|
|
'required' => true,
|
|
'description' => 'The api username of the Proxmox server',
|
|
],
|
|
[
|
|
'name' => 'token',
|
|
'label' => 'API Token',
|
|
'type' => 'text',
|
|
'required' => true,
|
|
'description' => 'The API Token of the Proxmox server',
|
|
],
|
|
[
|
|
'name' => 'verify_ssl',
|
|
'label' => 'Verify SSL',
|
|
'type' => 'checkbox',
|
|
'required' => false,
|
|
'default' => true,
|
|
'description' => 'Verify the SSL certificate of the Proxmox server',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get product config
|
|
*
|
|
* @param array $values
|
|
*/
|
|
public function getProductConfig($values = []): array
|
|
{
|
|
if (Node::count() == 0) {
|
|
return [
|
|
[
|
|
'name' => 'Notice',
|
|
'type' => 'placeholder',
|
|
'label' => new HtmlString('Could not find any nodes. Please create a node first. [<a class="text-primary-600" href="' . NodeResource::getUrl() . '">Create Node</a>]'),
|
|
],
|
|
];
|
|
}
|
|
|
|
$poolList = [];
|
|
try {
|
|
$resourcePool = $this->request('/pools');
|
|
foreach ($resourcePool->json()['data'] ?? [] as $pool) {
|
|
$poolList[] = [
|
|
'label' => $pool['poolid'],
|
|
'value' => $pool['poolid'],
|
|
];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
\Log::warning('Unable to load Proxmox pools for product config', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
return [
|
|
[
|
|
'name' => 'location_id',
|
|
'label' => 'Location',
|
|
'type' => 'select',
|
|
'required' => true,
|
|
'options' => Location::all()->map(function ($node) {
|
|
return [
|
|
'label' => $node->name,
|
|
'value' => $node->id,
|
|
];
|
|
})->toArray(),
|
|
'live' => true,
|
|
'description' => new HtmlString('Cannot find the location? Make sure you have created a location first. <a class="text-primary-600" href="' . LocationResource::getUrl('create') . '">Create Location</a>'),
|
|
],
|
|
[
|
|
'name' => 'ipv4',
|
|
'label' => 'Number of IPv4 Addresses',
|
|
'type' => 'number',
|
|
'required' => true,
|
|
],
|
|
[
|
|
'name' => 'ipv6',
|
|
'label' => 'Number of IPv6 Addresses',
|
|
'type' => 'number',
|
|
'required' => false,
|
|
],
|
|
[
|
|
'name' => 'pool',
|
|
'label' => 'Resource Pool',
|
|
'type' => 'select',
|
|
'required' => false,
|
|
'options' => $poolList,
|
|
'live' => true,
|
|
],
|
|
[
|
|
'name' => 'cpu',
|
|
'label' => 'CPU Model',
|
|
'type' => 'select',
|
|
'required' => true,
|
|
'options' => [
|
|
['label' => 'Broadwell-IBRS (GenuineIntel)', 'value' => 'Broadwell-IBRS'],
|
|
['label' => 'Skylake-Server-noTSX-IBRS (GenuineIntel)', 'value' => 'Skylake-Server-noTSX-IBRS'],
|
|
['label' => 'Icelake-Server (GenuineIntel)', 'value' => 'Icelake-Server'],
|
|
['label' => 'Icelake-Server-noTSX (GenuineIntel)', 'value' => 'Icelake-Server-noTSX'],
|
|
['label' => 'Icelake-Server-v6 (GenuineIntel)', 'value' => 'Icelake-Server-v6'],
|
|
['label' => 'EPYC-Rome-v4 (AuthenticAMD)', 'value' => 'EPYC-Rome-v4'],
|
|
['label' => 'EPYC-IBPB (AuthenticAMD)', 'value' => 'EPYC-IBPB'],
|
|
['label' => 'pentium3 (GenuineIntel)', 'value' => 'pentium3'],
|
|
['label' => 'host (default)', 'value' => 'host'],
|
|
['label' => 'EPYC-Rome-v2 (AuthenticAMD)', 'value' => 'EPYC-Rome-v2'],
|
|
['label' => 'Icelake-Server-v3 (GenuineIntel)', 'value' => 'Icelake-Server-v3'],
|
|
['label' => 'Cascadelake-Server (GenuineIntel)', 'value' => 'Cascadelake-Server'],
|
|
['label' => 'EPYC-Genoa (AuthenticAMD)', 'value' => 'EPYC-Genoa'],
|
|
['label' => 'Icelake-Client (GenuineIntel)', 'value' => 'Icelake-Client'],
|
|
['label' => 'Broadwell-noTSX-IBRS (GenuineIntel)', 'value' => 'Broadwell-noTSX-IBRS'],
|
|
['label' => 'SapphireRapids-v2 (GenuineIntel)', 'value' => 'SapphireRapids-v2'],
|
|
['label' => 'Icelake-Client-noTSX (GenuineIntel)', 'value' => 'Icelake-Client-noTSX'],
|
|
['label' => 'Cascadelake-Server-v2 (GenuineIntel)', 'value' => 'Cascadelake-Server-v2'],
|
|
['label' => 'Nehalem-IBRS (GenuineIntel)', 'value' => 'Nehalem-IBRS'],
|
|
['label' => 'EPYC (AuthenticAMD)', 'value' => 'EPYC'],
|
|
['label' => 'qemu64 (default)', 'value' => 'qemu64'],
|
|
['label' => 'Westmere (GenuineIntel)', 'value' => 'Westmere'],
|
|
['label' => 'Haswell-noTSX-IBRS (GenuineIntel)', 'value' => 'Haswell-noTSX-IBRS'],
|
|
['label' => 'Opteron_G2 (AuthenticAMD)', 'value' => 'Opteron_G2'],
|
|
['label' => 'Cooperlake (GenuineIntel)', 'value' => 'Cooperlake'],
|
|
['label' => 'Broadwell-noTSX (GenuineIntel)', 'value' => 'Broadwell-noTSX'],
|
|
['label' => 'Cascadelake-Server-v4 (GenuineIntel)', 'value' => 'Cascadelake-Server-v4'],
|
|
['label' => 'qemu32 (default)', 'value' => 'qemu32'],
|
|
['label' => 'Skylake-Server-v5 (GenuineIntel)', 'value' => 'Skylake-Server-v5'],
|
|
['label' => 'Opteron_G1 (AuthenticAMD)', 'value' => 'Opteron_G1'],
|
|
['label' => 'EPYC-Rome (AuthenticAMD)', 'value' => 'EPYC-Rome'],
|
|
['label' => 'Opteron_G4 (AuthenticAMD)', 'value' => 'Opteron_G4'],
|
|
['label' => 'max (default)', 'value' => 'max'],
|
|
['label' => 'core2duo (GenuineIntel)', 'value' => 'core2duo'],
|
|
['label' => 'athlon (AuthenticAMD)', 'value' => 'athlon'],
|
|
['label' => 'SandyBridge (GenuineIntel)', 'value' => 'SandyBridge'],
|
|
['label' => 'Opteron_G5 (AuthenticAMD)', 'value' => 'Opteron_G5'],
|
|
['label' => 'Skylake-Client-v4 (GenuineIntel)', 'value' => 'Skylake-Client-v4'],
|
|
['label' => 'Haswell-IBRS (GenuineIntel)', 'value' => 'Haswell-IBRS'],
|
|
['label' => 'EPYC-v4 (AuthenticAMD)', 'value' => 'EPYC-v4'],
|
|
['label' => 'Cooperlake-v2 (GenuineIntel)', 'value' => 'Cooperlake-v2'],
|
|
['label' => 'IvyBridge-IBRS (GenuineIntel)', 'value' => 'IvyBridge-IBRS'],
|
|
['label' => 'KnightsMill (GenuineIntel)', 'value' => 'KnightsMill'],
|
|
['label' => 'pentium (GenuineIntel)', 'value' => 'pentium'],
|
|
['label' => 'Cascadelake-Server-noTSX (GenuineIntel)', 'value' => 'Cascadelake-Server-noTSX'],
|
|
['label' => 'phenom (AuthenticAMD)', 'value' => 'phenom'],
|
|
['label' => 'Skylake-Server-v4 (GenuineIntel)', 'value' => 'Skylake-Server-v4'],
|
|
['label' => 'Westmere-IBRS (GenuineIntel)', 'value' => 'Westmere-IBRS'],
|
|
['label' => 'pentium2 (GenuineIntel)', 'value' => 'pentium2'],
|
|
['label' => 'kvm32 (default)', 'value' => 'kvm32'],
|
|
['label' => 'Skylake-Client-IBRS (GenuineIntel)', 'value' => 'Skylake-Client-IBRS'],
|
|
['label' => 'Skylake-Client (GenuineIntel)', 'value' => 'Skylake-Client'],
|
|
['label' => 'Nehalem (GenuineIntel)', 'value' => 'Nehalem'],
|
|
['label' => 'Cascadelake-Server-v5 (GenuineIntel)', 'value' => 'Cascadelake-Server-v5'],
|
|
['label' => '486 (GenuineIntel)', 'value' => '486'],
|
|
['label' => 'Penryn (GenuineIntel)', 'value' => 'Penryn'],
|
|
['label' => 'Conroe (GenuineIntel)', 'value' => 'Conroe'],
|
|
['label' => 'GraniteRapids (GenuineIntel)', 'value' => 'GraniteRapids'],
|
|
['label' => 'Skylake-Server-IBRS (GenuineIntel)', 'value' => 'Skylake-Server-IBRS'],
|
|
['label' => 'Skylake-Server (GenuineIntel)', 'value' => 'Skylake-Server'],
|
|
['label' => 'IvyBridge (GenuineIntel)', 'value' => 'IvyBridge'],
|
|
['label' => 'SapphireRapids (GenuineIntel)', 'value' => 'SapphireRapids'],
|
|
['label' => 'kvm64 (default)', 'value' => 'kvm64'],
|
|
['label' => 'EPYC-v3 (AuthenticAMD)', 'value' => 'EPYC-v3'],
|
|
['label' => 'EPYC-Milan (AuthenticAMD)', 'value' => 'EPYC-Milan'],
|
|
['label' => 'SandyBridge-IBRS (GenuineIntel)', 'value' => 'SandyBridge-IBRS'],
|
|
['label' => 'Broadwell (GenuineIntel)', 'value' => 'Broadwell'],
|
|
['label' => 'coreduo (GenuineIntel)', 'value' => 'coreduo'],
|
|
['label' => 'Icelake-Server-v4 (GenuineIntel)', 'value' => 'Icelake-Server-v4'],
|
|
['label' => 'Haswell (GenuineIntel)', 'value' => 'Haswell'],
|
|
['label' => 'EPYC-Milan-v2 (AuthenticAMD)', 'value' => 'EPYC-Milan-v2'],
|
|
['label' => 'Icelake-Server-v5 (GenuineIntel)', 'value' => 'Icelake-Server-v5'],
|
|
['label' => 'EPYC-Rome-v3 (AuthenticAMD)', 'value' => 'EPYC-Rome-v3'],
|
|
['label' => 'Skylake-Client-noTSX-IBRS (GenuineIntel)', 'value' => 'Skylake-Client-noTSX-IBRS'],
|
|
['label' => 'Haswell-noTSX (GenuineIntel)', 'value' => 'Haswell-noTSX'],
|
|
['label' => 'Opteron_G3 (AuthenticAMD)', 'value' => 'Opteron_G3'],
|
|
['label' => 'x86-64-v4 (default)', 'value' => 'x86-64-v4'],
|
|
['label' => 'x86-64-v3 (default)', 'value' => 'x86-64-v3'],
|
|
['label' => 'x86-64-v2-AES (default)', 'value' => 'x86-64-v2-AES'],
|
|
['label' => 'x86-64-v2 (default)', 'value' => 'x86-64-v2'],
|
|
],
|
|
'live' => true,
|
|
'default' => 'x86-64-v2-AES',
|
|
],
|
|
|
|
[
|
|
'name' => 'cores',
|
|
'type' => 'text',
|
|
'label' => 'CPU Cores',
|
|
'required' => true,
|
|
],
|
|
[
|
|
'name' => 'sockets',
|
|
'type' => 'number',
|
|
'label' => 'CPU Sockets',
|
|
],
|
|
[
|
|
'name' => 'memory',
|
|
'type' => 'text',
|
|
'label' => 'Memory (MiB)',
|
|
'required' => true,
|
|
],
|
|
[
|
|
'name' => 'disk',
|
|
'type' => 'text',
|
|
'label' => 'Disk (GiB)',
|
|
'required' => true,
|
|
],
|
|
[
|
|
'name' => 'backups',
|
|
'type' => 'number',
|
|
'label' => 'Backups',
|
|
'description' => 'Amount of backups user can create (0 = disabled)',
|
|
'default' => 0,
|
|
],
|
|
[
|
|
'name' => 'cache',
|
|
'type' => 'select',
|
|
'label' => 'Cache',
|
|
'description' => 'The cache of the VM',
|
|
'options' => [
|
|
[
|
|
'label' => 'Default (no cache)',
|
|
'value' => 'default',
|
|
],
|
|
[
|
|
'label' => 'Direct Sync',
|
|
'value' => 'directsync',
|
|
],
|
|
[
|
|
'label' => 'Write Through',
|
|
'value' => 'writethrough',
|
|
],
|
|
[
|
|
'label' => 'Write Back',
|
|
'value' => 'writeback',
|
|
],
|
|
[
|
|
'label' => 'Write Back (unsafe)',
|
|
'value' => 'unsafe',
|
|
],
|
|
[
|
|
'label' => 'No Cache',
|
|
'value' => 'none',
|
|
],
|
|
],
|
|
'default' => 'default',
|
|
],
|
|
|
|
[
|
|
'name' => 'model',
|
|
'type' => 'select',
|
|
'label' => 'Network Model',
|
|
'options' => [
|
|
[
|
|
'label' => 'VirtIO',
|
|
'value' => 'virtio',
|
|
],
|
|
[
|
|
'label' => 'Intel E1000',
|
|
'value' => 'e1000',
|
|
],
|
|
[
|
|
'label' => 'Realtek RTL8139',
|
|
'value' => 'rtl8139',
|
|
],
|
|
[
|
|
'label' => 'VMWare VMXNET3',
|
|
'value' => 'vmxnet3',
|
|
],
|
|
],
|
|
'default' => 'virtio',
|
|
'required' => true,
|
|
],
|
|
[
|
|
'name' => 'ratelimit',
|
|
'type' => 'text',
|
|
'label' => 'Network Rate limit (MB/s)',
|
|
'placeholder' => 'unlimited',
|
|
],
|
|
];
|
|
}
|
|
|
|
public function getCheckoutConfig(Product $product)
|
|
{
|
|
$locationSetting = $product->settings()->where('key', 'location_id')->first();
|
|
if (!$locationSetting) {
|
|
return [['name' => 'notice', 'type' => 'placeholder', 'label' => new \Illuminate\Support\HtmlString('<span class="text-red-500">Product is missing a location_id setting. Please contact support.</span>')]];
|
|
}
|
|
$oses = Location::findOrFail($locationSetting->value)->os()->get();
|
|
|
|
return [
|
|
[
|
|
'name' => 'hostname',
|
|
'label' => 'FQDN Hostname - IETF RFC-1035 Compliant',
|
|
'type' => 'text',
|
|
'required' => true,
|
|
'description' => 'In computer networking, a hostname is a label that is assigned to a device connected to a computer network and that is used to identify the device in various forms of electronic communication, such as the World Wide Web. Hostnames may be simple names consisting of a single word or phrase, or they may be structured. Each hostname usually has at least one numeric network address associated with it for routing packets for performance and other reasons. The form of this hostname must be compliant with IETF RFC-1035. More info: https://datatracker.ietf.org/doc/html/rfc1035. The hostname may include lowercase letters (a-z), numbers (0-9), and hyphens (-). The hostname must start and end with a letter or number. The hostname may include periods (.) to separate domain levels, but not at the start or end of the hostname, and periods must not be consecutive.',
|
|
'validation' => 'required|regex:/^(?!.*\.\.)[a-z0-9]([a-z0-9.-]{0,126}[a-z0-9])?$/',
|
|
],
|
|
[
|
|
'name' => 'os',
|
|
'label' => 'Operating System Selection (*nix Based Systems)',
|
|
'type' => 'select',
|
|
'required' => true,
|
|
'options' => $oses->pluck('name', 'id')->toArray(),
|
|
'description' => 'An operating system (OS) is system software that manages computer hardware and software resources, and provides common services for computer programs. Any system in the selection must used in accordance with their individual licenses. Virtnet.bond does not provide support individual for any operating systems, nor do we provide any licenses for paid operating systems. The scope of our support is limited to the hardware and connectivity we provide.',
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if currenct configuration is valid
|
|
*/
|
|
public function testConfig(): bool|string
|
|
{
|
|
try {
|
|
$response = $this->request('/version');
|
|
if ($response->successful()) {
|
|
return true;
|
|
}
|
|
|
|
return 'Unable to connect to Proxmox API.';
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
return 'Unable to connect to Proxmox API.';
|
|
}
|
|
}
|
|
|
|
public function requestAndWait($url, $method = 'get', $data = [], $node = null, int $timeoutSeconds = 300)
|
|
{
|
|
$response = $this->request($url, $method, $data, timeoutSeconds: 30, connectTimeoutSeconds: 10);
|
|
|
|
// Check if the task is running
|
|
$task = $response->json()['data'] ?? null;
|
|
sleep(1); // Give Proxmox some time to start the task
|
|
|
|
if (!$task) {
|
|
return 'error';
|
|
}
|
|
|
|
return $this->waitForTask($task, $node, $timeoutSeconds);
|
|
}
|
|
|
|
public function waitForTask($task, $node, int $timeoutSeconds = 300)
|
|
{
|
|
if (!$node) {
|
|
\Log::warning('waitForTask called without node context', ['task' => $task]);
|
|
return 'error';
|
|
}
|
|
|
|
$deadline = now()->addSeconds(max(1, $timeoutSeconds))->timestamp;
|
|
$status = null;
|
|
$lastError = null;
|
|
|
|
while (now()->timestamp <= $deadline) {
|
|
try {
|
|
$status = $this->request('/nodes/' . $node . '/tasks/' . $task . '/status', timeoutSeconds: 15, connectTimeoutSeconds: 5)
|
|
->json()['data']['status'] ?? null;
|
|
} catch (\Throwable $e) {
|
|
$lastError = $e->getMessage();
|
|
}
|
|
|
|
if ($status !== 'running') {
|
|
return $status ?? 'error';
|
|
}
|
|
|
|
sleep(1);
|
|
}
|
|
|
|
\Log::warning('Proxmox task timed out before completion', [
|
|
'task' => $task,
|
|
'node' => $node,
|
|
'timeout_seconds' => $timeoutSeconds,
|
|
'last_status' => $status,
|
|
'last_error' => $lastError,
|
|
]);
|
|
|
|
return 'timeout';
|
|
}
|
|
|
|
private function generateMacAddress(): string
|
|
{
|
|
$mac = [];
|
|
$bytes = random_bytes(6);
|
|
for ($i = 0; $i < 6; $i++) {
|
|
$mac[] = str_pad(dechex(ord($bytes[$i])), 2, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
// Get the first octet as an integer
|
|
$firstOctet = hexdec($mac[0]);
|
|
|
|
// Set the locally administered bit (bit 1) and clear the multicast bit (bit 0)
|
|
$firstOctet = ($firstOctet | 0x02) & 0xFE;
|
|
|
|
// Convert back to hex and pad
|
|
$mac[0] = str_pad(dechex($firstOctet), 2, '0', STR_PAD_LEFT);
|
|
|
|
return implode(':', $mac);
|
|
}
|
|
|
|
public function install(Server $server, $settings, $password)
|
|
{
|
|
// Server to installing so user can see it in the UI
|
|
$server->status = 'installing';
|
|
|
|
$server->save();
|
|
|
|
// Use the OS template's type for cloning (source type determines API path)
|
|
// The template type should match the node type (LXC templates for LXC nodes, KVM for KVM)
|
|
$templateVmType = $server->os->vmType();
|
|
$nodeVmType = $server->node->vmType();
|
|
|
|
// Warn if there's a type mismatch (shouldn't happen if OS selection is filtered correctly)
|
|
if ($templateVmType !== $nodeVmType) {
|
|
\Log::warning('OS template type does not match node type', [
|
|
'server_id' => $server->id,
|
|
'os_type' => $templateVmType,
|
|
'node_type' => $nodeVmType,
|
|
]);
|
|
}
|
|
|
|
// Create the VM/CT - use template's vmType for source path
|
|
// Clone parameters differ between KVM and LXC
|
|
$cloneParams = [
|
|
'newid' => $server->vm_id,
|
|
'target' => $server->node->name,
|
|
'full' => 1,
|
|
'storage' => $server->node->storage_location,
|
|
'pool' => $settings['pool'] ?? null,
|
|
];
|
|
|
|
// KVM accepts 'name' for hostname during clone, LXC sets hostname via config after clone
|
|
if ($templateVmType === 'qemu') {
|
|
$cloneParams['name'] = $server->hostname;
|
|
}
|
|
|
|
$cloneResult = $this->requestAndWait('/nodes/' . $server->node->name . '/' . $templateVmType . '/' . $server->os->vm_id . '/clone', 'post', $cloneParams, $server->node->name);
|
|
|
|
if ($cloneResult !== 'stopped' && $cloneResult !== 'OK') {
|
|
\Log::error('Proxmox VM/CT clone failed for server ' . $server->id, [
|
|
'result' => $cloneResult,
|
|
'vm_id' => $server->vm_id,
|
|
'node' => $server->node->name,
|
|
'type' => $templateVmType,
|
|
]);
|
|
$server->status = 'error';
|
|
$server->save();
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if ($server->node->is_lxc) {
|
|
$this->syncLxcSettings($server, $settings, $password);
|
|
} else {
|
|
$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();
|
|
|
|
return;
|
|
}
|
|
|
|
// Mark server as active
|
|
$server->status = 'active';
|
|
$server->password = $password;
|
|
$server->save();
|
|
}
|
|
|
|
private function syncServerSettings(Server $server, $settings, $password = null)
|
|
{
|
|
$ipv4 = $server->primaryIpv4;
|
|
$ipv6 = $server->primaryIpv6;
|
|
|
|
$data = [
|
|
'cores' => $settings['cores'],
|
|
'sockets' => $settings['sockets'] ?? 1,
|
|
'memory' => $settings['memory'],
|
|
'cpu' => $settings['cpu'],
|
|
];
|
|
|
|
if ($password) {
|
|
$data['cipassword'] = $password;
|
|
}
|
|
// Update cores and ram
|
|
$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'] ?? [];
|
|
// Find the primary disk across all bus types and slot numbers
|
|
$diskType = null;
|
|
foreach (['scsi', 'virtio', 'sata', 'ide'] as $bus) {
|
|
for ($i = 0; $i < 4; $i++) {
|
|
$key = $bus . $i;
|
|
if (isset($disk[$key]) && !str_contains($disk[$key], 'media=cdrom') && !str_contains(strtolower($disk[$key]), 'cloudinit')) {
|
|
$diskType = $key;
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
$diskType = $diskType ?? 'scsi0';
|
|
|
|
// Update disk
|
|
$this->requestAndWait('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/resize', 'put', [
|
|
'size' => $settings['disk'] . 'G',
|
|
'disk' => $diskType,
|
|
], $server->node->name);
|
|
|
|
$config = [];
|
|
// Check if we have both IPv4 and IPv6 addresses AND they are in the same pool and same mac address (or none)
|
|
if ($ipv4 && $ipv6 && $ipv4->ip_pool_id == $ipv6->ip_pool_id && ($ipv4->mac == $ipv6->mac || !$ipv4->mac || !$ipv6->mac)) {
|
|
$location = $ipv4->ipPool;
|
|
// Same pool, so we can use the same network config
|
|
$network = $settings['model'] . '=' . $this->generateMacAddress() . ',bridge=' . $location->network_interface;
|
|
$network = $this->applyPoolSettings($location, $network);
|
|
if (isset($settings['ratelimit'])) {
|
|
$network .= ',rate=' . $settings['ratelimit'];
|
|
}
|
|
$config['net0'] = $network;
|
|
$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;
|
|
$network = $this->applyPoolSettings($ipv4->ipPool, $network);
|
|
if (isset($settings['ratelimit'])) {
|
|
$network .= ',rate=' . $settings['ratelimit'];
|
|
}
|
|
$config['net0'] = $network;
|
|
$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;
|
|
$network = $this->applyPoolSettings($ipv6->ipPool, $network);
|
|
if (isset($settings['ratelimit'])) {
|
|
$network .= ',rate=' . $settings['ratelimit'];
|
|
}
|
|
$config['net1'] = $network;
|
|
$ipv6Cidr = $ipv6->netmask ?: '64';
|
|
$config['ipconfig1'] = 'ip6=' . $ipv6->ip . '/' . $ipv6Cidr . ',gw6=' . $ipv6->gateway;
|
|
}
|
|
}
|
|
|
|
$this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config', 'put', $config);
|
|
}
|
|
|
|
/**
|
|
* Sync LXC container settings after cloning.
|
|
*/
|
|
private function syncLxcSettings(Server $server, $settings, $password = null)
|
|
{
|
|
$ipv4 = $server->primaryIpv4;
|
|
$ipv6 = $server->primaryIpv6;
|
|
|
|
$data = [
|
|
'cores' => $settings['cores'],
|
|
'memory' => $settings['memory'],
|
|
'swap' => $settings['swap'] ?? 512,
|
|
'hostname' => $server->hostname,
|
|
];
|
|
|
|
// Note: LXC password cannot be set via config PUT - only during pct create.
|
|
// Password from cloned template is preserved, or use pct exec to change it.
|
|
|
|
// Resize rootfs disk
|
|
$this->requestAndWait('/nodes/' . $server->node->name . '/lxc/' . $server->vm_id . '/resize', 'put', [
|
|
'disk' => 'rootfs',
|
|
'size' => $settings['disk'] . 'G',
|
|
], $server->node->name);
|
|
|
|
// Build network config for LXC
|
|
// LXC net format: name=eth0,bridge=vmbr0,ip=x.x.x.x/24,gw=x.x.x.x[,tag=VLAN][,firewall=1][,rate=X]
|
|
if ($ipv4 && $ipv6 && $ipv4->ip_pool_id == $ipv6->ip_pool_id) {
|
|
$location = $ipv4->ipPool;
|
|
$ipv4Cidr = $ipv4->netmask ?: '24';
|
|
$ipv6Cidr = $ipv6->netmask ?: '64';
|
|
$net = 'name=eth0,bridge=' . $location->network_interface;
|
|
$net .= ',ip=' . $ipv4->ip . '/' . $ipv4Cidr . ',gw=' . $ipv4->gateway;
|
|
$net .= ',ip6=' . $ipv6->ip . '/' . $ipv6Cidr . ',gw6=' . $ipv6->gateway;
|
|
$net = $this->applyLxcPoolSettings($location, $net);
|
|
if (isset($settings['ratelimit'])) {
|
|
$net .= ',rate=' . $settings['ratelimit'];
|
|
}
|
|
$data['net0'] = $net;
|
|
} else {
|
|
if ($ipv4) {
|
|
$ipv4Cidr = $ipv4->netmask ?: '24';
|
|
$net = 'name=eth0,bridge=' . $ipv4->ipPool->network_interface;
|
|
$net .= ',ip=' . $ipv4->ip . '/' . $ipv4Cidr . ',gw=' . $ipv4->gateway;
|
|
$net = $this->applyLxcPoolSettings($ipv4->ipPool, $net);
|
|
if (isset($settings['ratelimit'])) {
|
|
$net .= ',rate=' . $settings['ratelimit'];
|
|
}
|
|
$data['net0'] = $net;
|
|
}
|
|
if ($ipv6) {
|
|
$ipv6Cidr = $ipv6->netmask ?: '64';
|
|
$net = 'name=eth1,bridge=' . $ipv6->ipPool->network_interface;
|
|
$net .= ',ip6=' . $ipv6->ip . '/' . $ipv6Cidr . ',gw6=' . $ipv6->gateway;
|
|
$net = $this->applyLxcPoolSettings($ipv6->ipPool, $net);
|
|
if (isset($settings['ratelimit'])) {
|
|
$net .= ',rate=' . $settings['ratelimit'];
|
|
}
|
|
$data['net1'] = $net;
|
|
}
|
|
}
|
|
|
|
// Update container config
|
|
$this->request('/nodes/' . $server->node->name . '/lxc/' . $server->vm_id . '/config', 'put', $data);
|
|
}
|
|
|
|
private function applyLxcPoolSettings(IPPool $ipPool, $string)
|
|
{
|
|
if ($ipPool->vlan) {
|
|
$string .= ',tag=' . $ipPool->vlan;
|
|
}
|
|
if ($ipPool->firewall) {
|
|
$string .= ',firewall=1';
|
|
}
|
|
if ($ipPool->mtu) {
|
|
$string .= ',mtu=' . $ipPool->mtu;
|
|
}
|
|
|
|
return $string;
|
|
}
|
|
|
|
private function applyPoolSettings(IPPool $ipPool, $string)
|
|
{
|
|
if ($ipPool->vlan) {
|
|
$string .= ',tag=' . $ipPool->vlan;
|
|
}
|
|
if ($ipPool->firewall) {
|
|
$string .= ',firewall=1';
|
|
}
|
|
if ($ipPool->mtu) {
|
|
$string .= ',mtu=' . $ipPool->mtu;
|
|
}
|
|
|
|
return $string;
|
|
}
|
|
|
|
public function reinstall(Server $server)
|
|
{
|
|
ReinstallJob::dispatch($server);
|
|
}
|
|
|
|
private function calculateNode(Location $location, Service $service, $settings, ?bool $isLxc = null)
|
|
{
|
|
// What type of selection do we want to use?
|
|
/* 'random' => 'Random Node',
|
|
'fill' => 'Fill Nodes (fill each node before moving to the next)',
|
|
'least_loaded_server' => 'Least Loaded Node (based on server count)',
|
|
'least_loaded_ram' => 'Least Loaded Node (based on RAM)',
|
|
'least_loaded_disk' => 'Least Loaded Node (based on disk)',
|
|
'least_loaded_cpu' => 'Least Loaded Node (based on CPU)',
|
|
*/
|
|
$cpuNeeded = $settings['cores'] * ($settings['sockets'] ?? 1);
|
|
$memoryNeeded = (int) $settings['memory'];
|
|
$diskNeeded = (int) $settings['disk'];
|
|
|
|
// Build base query for nodes, optionally filtering by type (LXC or KVM)
|
|
$nodeQuery = $location->nodes();
|
|
if ($isLxc !== null) {
|
|
$nodeQuery->where('is_lxc', $isLxc);
|
|
}
|
|
|
|
if ($location->server_placement == 'random') {
|
|
$nodes = (clone $nodeQuery)->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 = (clone $nodeQuery)->get();
|
|
$node = null;
|
|
foreach ($nodes as $n) {
|
|
if ($n->canStoreServer($memoryNeeded, $diskNeeded, $cpuNeeded)) {
|
|
$node = $n;
|
|
break;
|
|
}
|
|
}
|
|
} elseif ($location->server_placement == 'least_loaded_server') {
|
|
// Get the node with the least
|
|
$nodes = (clone $nodeQuery)->withCount('servers')->orderBy('servers_count', 'asc')->get();
|
|
$node = null;
|
|
foreach ($nodes as $n) {
|
|
if ($n->canStoreServer($memoryNeeded, $diskNeeded, $cpuNeeded)) {
|
|
$node = $n;
|
|
break;
|
|
}
|
|
}
|
|
} elseif ($location->server_placement == 'least_loaded_ram') {
|
|
$nodes = (clone $nodeQuery)->withCount('servers')->orderBy('ram', 'asc')->get();
|
|
$nodeAndRam = [];
|
|
foreach ($nodes as $n) {
|
|
if ($n->canStoreServer($memoryNeeded, $diskNeeded, $cpuNeeded)) {
|
|
$nodeAndRam[$n->id] = $n->ram - $n->resourcesAvailable()['memory'];
|
|
}
|
|
}
|
|
asort($nodeAndRam);
|
|
$node = null;
|
|
foreach ($nodeAndRam as $id => $ram) {
|
|
$node = Node::findOrFail($id);
|
|
break;
|
|
}
|
|
} elseif ($location->server_placement == 'least_loaded_disk') {
|
|
$nodes = (clone $nodeQuery)->withCount('servers')->orderBy('disk', 'asc')->get();
|
|
$nodeAndDisk = [];
|
|
foreach ($nodes as $n) {
|
|
if ($n->canStoreServer($memoryNeeded, $diskNeeded, $cpuNeeded)) {
|
|
$nodeAndDisk[$n->id] = $n->disk - $n->resourcesAvailable()['disk'];
|
|
}
|
|
}
|
|
asort($nodeAndDisk);
|
|
$node = null;
|
|
foreach ($nodeAndDisk as $id => $disk) {
|
|
$node = Node::findOrFail($id);
|
|
break;
|
|
}
|
|
} elseif ($location->server_placement == 'least_loaded_cpu') {
|
|
$nodes = (clone $nodeQuery)->withCount('servers')->orderBy('cpu', 'asc')->get();
|
|
$nodeAndCpu = [];
|
|
foreach ($nodes as $n) {
|
|
if ($n->canStoreServer($memoryNeeded, $diskNeeded, $cpuNeeded)) {
|
|
$nodeAndCpu[$n->id] = $n->cpu - $n->resourcesAvailable()['cpu'];
|
|
}
|
|
}
|
|
asort($nodeAndCpu);
|
|
$node = null;
|
|
foreach ($nodeAndCpu as $id => $cpu) {
|
|
$node = Node::findOrFail($id);
|
|
break;
|
|
}
|
|
} else {
|
|
throw new Exception('Invalid server placement');
|
|
}
|
|
if (!$node) {
|
|
throw new Exception('No node found with enough resources');
|
|
}
|
|
|
|
return $node;
|
|
}
|
|
|
|
private function getAvailableIps(Node $node, $amount, $type = 'ipv4')
|
|
{
|
|
// Get ippools, then check if a pool has enough IPs
|
|
$pool = $node->ipPools()->whereHas('ipAddresses', function ($query) use ($type) {
|
|
$query->whereNull('server_id')->where('type', $type);
|
|
})->withCount(['ipAddresses' => function ($query) use ($type) {
|
|
$query->whereNull('server_id')->where('type', $type);
|
|
}])->get()->filter(function ($pool) use ($amount) {
|
|
return $pool->ip_addresses_count >= $amount;
|
|
});
|
|
|
|
if ($pool->count() == 0) {
|
|
throw new Exception('No IP pool found with enough available ' . strtoupper($type) . ' addresses');
|
|
}
|
|
$pool = $pool->first();
|
|
|
|
return (object) [
|
|
'ips' => $pool->ipAddresses()->whereNull('server_id')->where('type', $type)->take($amount)->get(),
|
|
'pool' => $pool,
|
|
];
|
|
}
|
|
|
|
public function recreateVncUser(Server $server, bool $forceRotate = false)
|
|
{
|
|
$cacheKey = $this->consoleCredentialCacheKey($server);
|
|
if (!$forceRotate) {
|
|
$cachedPassword = Cache::get($cacheKey);
|
|
if (is_string($cachedPassword) && trim($cachedPassword) !== '') {
|
|
return $cachedPassword;
|
|
}
|
|
}
|
|
|
|
$userId = $this->consoleUserId($server);
|
|
$password = Str::password();
|
|
|
|
// Reuse existing user when possible, create if missing.
|
|
try {
|
|
$this->request('/access/users/' . $userId, 'put', [
|
|
'enable' => 1,
|
|
'password' => $password,
|
|
]);
|
|
} catch (Exception $e) {
|
|
$this->request('/access/users', 'post', [
|
|
'userid' => $userId,
|
|
'enable' => 1,
|
|
'password' => $password,
|
|
]);
|
|
}
|
|
|
|
// Ensure role exists with correct permissions
|
|
try {
|
|
// Try to update the role permissions (in case it exists with old permissions)
|
|
$this->request('/access/roles/PAYMENTER_VNC', 'PUT', [
|
|
'privs' => 'VM.Console,VM.Audit',
|
|
]);
|
|
} catch (Exception $e) {
|
|
// Role doesn't exist, create it with VM.Console and VM.Audit permissions
|
|
$this->request('/access/roles', 'post', [
|
|
'roleid' => 'PAYMENTER_VNC',
|
|
'privs' => 'VM.Console,VM.Audit',
|
|
]);
|
|
}
|
|
|
|
// Assign role to user
|
|
$this->request('/access/acl', 'PUT', [
|
|
'path' => '/vms/' . $server->vm_id,
|
|
'roles' => 'PAYMENTER_VNC',
|
|
'users' => $userId,
|
|
]);
|
|
|
|
Cache::put($cacheKey, $password, now()->addSeconds(self::CONSOLE_CREDENTIAL_TTL_SECONDS));
|
|
|
|
return $password;
|
|
}
|
|
|
|
private function consoleUserId(Server $server): string
|
|
{
|
|
return 'PAYMENTER_VNC_' . $server->id . '@pve';
|
|
}
|
|
|
|
private function consoleCredentialCacheKey(Server $server): string
|
|
{
|
|
return 'proxmox-console-user-password-' . $server->id;
|
|
}
|
|
|
|
private function createConsoleTicket(Server $server): array
|
|
{
|
|
$attempts = [false, true];
|
|
|
|
foreach ($attempts as $forceRotate) {
|
|
try {
|
|
$password = $this->recreateVncUser($server, $forceRotate);
|
|
|
|
$response = Http::withOptions([
|
|
'verify' => $this->config('verify_ssl') ? true : false,
|
|
])->post(rtrim($this->config('host'), '/') . '/api2/json/access/ticket', [
|
|
'username' => $this->consoleUserId($server),
|
|
'password' => $password,
|
|
])->throw()->json()['data'] ?? [];
|
|
|
|
if (!empty($response['ticket'])) {
|
|
return $response;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
if ($forceRotate) {
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new \RuntimeException('Failed to obtain Proxmox authentication ticket.');
|
|
}
|
|
|
|
public function vnc(Server $server)
|
|
{
|
|
$response = $this->createConsoleTicket($server);
|
|
|
|
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
|
|
$domain = preg_replace('/^https?:\/\//', '', $domain);
|
|
$domain = preg_replace('/:\d+$/', '', $domain);
|
|
|
|
$vnc = Http::withCookies([
|
|
'PVEAuthCookie' => $response['ticket'],
|
|
], $domain)
|
|
->withHeader('CSRFPreventionToken', $response['CSRFPreventionToken'])
|
|
->withOptions([
|
|
'verify' => $this->config('verify_ssl') ? true : false,
|
|
])->post(rtrim($this->config('host'), '/') . '/api2/json/nodes/' . $server->node->name . '/' . $server->node->vmType() . '/' . $server->vm_id . '/vncproxy', [
|
|
'websocket' => 1,
|
|
])->throw()->json()['data'] ?? [];
|
|
|
|
$this->setConsoleAuthCookie($response['ticket']);
|
|
|
|
$consoleType = $server->node->is_lxc ? 'lxc' : 'kvm';
|
|
$vmType = $server->node->vmType();
|
|
|
|
return rtrim($this->config('host'), '/') .
|
|
'/?console=' . $consoleType .
|
|
'&novnc=1' .
|
|
'&node=' . $server->node->name .
|
|
'&resize=1' .
|
|
'&vmid=' . $server->vm_id .
|
|
'&path=/api2/json/nodes/' . $server->node->name . '/' . $vmType . '/' . $server->vm_id . '/vncwebsocket';
|
|
}
|
|
|
|
public function xterm(Server $server)
|
|
{
|
|
$response = $this->createConsoleTicket($server);
|
|
|
|
if (empty($response['ticket'])) {
|
|
throw new \RuntimeException('Failed to obtain Proxmox authentication ticket.');
|
|
}
|
|
|
|
$domain = rtrim($this->config('host'), '/');
|
|
// Remove port and http
|
|
$domain = preg_replace('/^https?:\/\//', '', $domain);
|
|
$domain = preg_replace('/:\d+$/', '', $domain);
|
|
|
|
$this->setConsoleAuthCookie($response['ticket']);
|
|
|
|
$consoleType = $server->node->is_lxc ? 'lxc' : 'kvm';
|
|
|
|
return rtrim($this->config('host'), '/') .
|
|
'/?console=' . $consoleType .
|
|
'&xtermjs=1' .
|
|
'&node=' . $server->node->name .
|
|
'&vmid=' . $server->vm_id;
|
|
}
|
|
|
|
private function setConsoleAuthCookie(string $ticket): void
|
|
{
|
|
$cookieDomain = $this->derivePaymenterCookieDomain();
|
|
|
|
setcookie('PVEAuthCookie', $ticket, 0, '/', $cookieDomain ?? '', true, true);
|
|
}
|
|
|
|
private function derivePaymenterCookieDomain(): ?string
|
|
{
|
|
$appUrl = (string) config('settings.app_url');
|
|
if (trim($appUrl) === '') {
|
|
return null;
|
|
}
|
|
|
|
$host = parse_url($appUrl, PHP_URL_HOST);
|
|
if (!is_string($host) || trim($host) === '') {
|
|
return null;
|
|
}
|
|
|
|
$host = strtolower(trim($host));
|
|
|
|
// Domain cookies are invalid for IPs/localhost/single-label hosts.
|
|
if (filter_var($host, FILTER_VALIDATE_IP) || !str_contains($host, '.')) {
|
|
return null;
|
|
}
|
|
|
|
return $host;
|
|
}
|
|
|
|
/**
|
|
* Create a server
|
|
*
|
|
* @param array $settings (product settings)
|
|
* @param array $properties (checkout options)
|
|
* @return bool
|
|
*/
|
|
public function createServer(Service $service, $settings, $properties)
|
|
{
|
|
if (Server::where('service_id', $service->id)->exists()) {
|
|
throw new Exception('Server already exists');
|
|
}
|
|
|
|
// Define which node is gonna be used
|
|
$settings = array_merge($settings, $properties);
|
|
|
|
$location = Location::findOrFail($settings['location_id']);
|
|
|
|
// Get the OS first to determine if it's LXC or KVM
|
|
$os = $location->os()->findOrFail($settings['os']);
|
|
|
|
// Calculate node - pass the OS type so it selects a matching node (LXC or KVM)
|
|
$node = $this->calculateNode($location, $service, $settings, $os->is_lxc);
|
|
|
|
// Verify OS type matches node type
|
|
if ($os->is_lxc !== ($node->is_lxc ?? false)) {
|
|
throw new Exception('OS template type (' . ($os->is_lxc ? 'LXC' : 'KVM') . ') does not match available node type (' . ($node->is_lxc ? 'LXC' : 'KVM') . '). Please select a different OS or contact support.');
|
|
}
|
|
|
|
// Get ippools, then check if a pool has enough IPs
|
|
$ipv4 = (object) [
|
|
'ips' => collect(),
|
|
'pool' => null,
|
|
];
|
|
if (isset($settings['ipv4']) && $settings['ipv4'] > 0) {
|
|
$ipv4 = $this->getAvailableIps($node, $settings['ipv4'], 'ipv4');
|
|
}
|
|
$ipv6 = (object) [
|
|
'ips' => collect(),
|
|
'pool' => null,
|
|
];
|
|
if (isset($settings['ipv6']) && $settings['ipv6'] > 0) {
|
|
$ipv6 = $this->getAvailableIps($node, $settings['ipv6'], 'ipv6');
|
|
}
|
|
|
|
// 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 . ')');
|
|
}
|
|
|
|
$password = Str::password(16);
|
|
|
|
// Create the server
|
|
$server = new Server;
|
|
$server->vm_id = $vm_id;
|
|
$server->node_id = $node->id;
|
|
$server->os_id = $os->id;
|
|
$server->service_id = $service->id;
|
|
$server->primary_ipv4 = $ipv4->ips->first()->id ?? null;
|
|
$server->primary_ipv6 = $ipv6->ips->first()->id ?? null;
|
|
$server->user_id = $service->user_id;
|
|
$server->hostname = $properties['hostname'];
|
|
$server->password = $password;
|
|
$server->save();
|
|
$node->forgetResourceCache();
|
|
|
|
// Update the IPs
|
|
foreach ($ipv4->ips as $ip) {
|
|
$ip->server_id = $server->id;
|
|
$ip->save();
|
|
}
|
|
foreach ($ipv6->ips as $ip) {
|
|
$ip->server_id = $server->id;
|
|
$ip->save();
|
|
}
|
|
|
|
$this->install($server, $settings, $password);
|
|
|
|
return [
|
|
'password' => $password,
|
|
'ip' => $server->primaryIpv4->ip ?? null,
|
|
'ipv6' => $server->primaryIpv6->ip ?? null,
|
|
'hostname' => $properties['hostname'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Suspend a server
|
|
*
|
|
* @param array $settings (product settings)
|
|
* @param array $properties (checkout options)
|
|
* @return bool
|
|
*/
|
|
public function suspendServer(Service $service, $settings, $properties)
|
|
{
|
|
// Update server status
|
|
$server = Server::where('service_id', $service->id)->first();
|
|
|
|
if (!$server) {
|
|
return false;
|
|
}
|
|
|
|
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
|
|
$vmType = $server->node->vmType();
|
|
$this->requestAndWait('/nodes/' . $server->node->name . '/' . $vmType . '/' . $server->vm_id . '/status/stop', 'post', [
|
|
'vmid' => $server->vm_id,
|
|
], $server->node->name);
|
|
|
|
$server->status = 'suspended';
|
|
$server->save();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Unsuspend a server
|
|
*
|
|
* @param array $settings (product settings)
|
|
* @param array $properties (checkout options)
|
|
* @return bool
|
|
*/
|
|
public function unsuspendServer(Service $service, $settings, $properties)
|
|
{
|
|
// Update server status
|
|
$server = Server::where('service_id', $service->id)->first();
|
|
|
|
if (!$server) {
|
|
return false;
|
|
}
|
|
|
|
if ($server->node) {
|
|
try {
|
|
$vmType = $server->node->vmType();
|
|
$this->requestAndWait('/nodes/' . $server->node->name . '/' . $vmType . '/' . $server->vm_id . '/status/start', 'post', [
|
|
'vmid' => $server->vm_id,
|
|
], $server->node->name);
|
|
} catch (\Throwable $e) {
|
|
\Log::warning('Failed to start VM on unsuspend', ['server' => $server->id, 'error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
$server->status = 'active';
|
|
$server->save();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Terminate a server
|
|
*
|
|
* @param array $settings (product settings)
|
|
* @param array $properties (checkout options)
|
|
* @return bool
|
|
*/
|
|
public function terminateServer(Service $service, $settings, $properties)
|
|
{
|
|
if (!Server::where('service_id', $service->id)->exists()) {
|
|
return false;
|
|
}
|
|
|
|
$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/CT and wait for completion before deleting
|
|
$vmType = $server->node->vmType();
|
|
$this->requestAndWait('/nodes/' . $server->node->name . '/' . $vmType . '/' . $server->vm_id . '/status/stop', 'post', [
|
|
'vmid' => $server->vm_id,
|
|
], $server->node->name);
|
|
// Delete proxmox server
|
|
$this->request('/nodes/' . $server->node->name . '/' . $vmType . '/' . $server->vm_id, 'delete');
|
|
// Release IP addresses so they can be reallocated
|
|
$server->ipAddresses()->update(['server_id' => null]);
|
|
// Delete server
|
|
$node = $server->node;
|
|
$server->delete();
|
|
$node->forgetResourceCache();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Upgrade a server
|
|
*
|
|
* @param array $settings (product settings)
|
|
* @param array $properties (checkout options)
|
|
* @return bool
|
|
*/
|
|
public function upgradeServer(Service $service, $settings, $properties)
|
|
{
|
|
$server = Server::where('service_id', $service->id)->first();
|
|
|
|
if (!$server) {
|
|
return false;
|
|
}
|
|
|
|
if (!$server->node) {
|
|
\Log::error('Proxmox upgradeServer: node relationship is null for server ' . $server->id);
|
|
return false;
|
|
}
|
|
|
|
// Update the server settings
|
|
if ($server->node->is_lxc) {
|
|
$this->syncLxcSettings($server, $settings, null);
|
|
} else {
|
|
$this->syncServerSettings($server, $settings, null);
|
|
}
|
|
$server->node->forgetResourceCache();
|
|
|
|
return true;
|
|
}
|
|
|
|
public function getActions(Service $service)
|
|
{
|
|
if (Server::where('service_id', $service->id)->exists()) {
|
|
return [
|
|
[
|
|
'type' => 'button',
|
|
'label' => 'Go to server',
|
|
'url' => route('proxmox.server.overview', ['server' => Server::where('service_id', $service->id)->first()]),
|
|
],
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
}
|