Files
Virtnet.bond/extensions/Servers/Proxmox/Proxmox.php
Joseph.Rawlings 061ec6145c Add storage management functionality to Proxmox extension
- Registered Livewire component for storage management.
- Updated routes to include storage management endpoint.
- Modified layout to add storage management link.
- Created storage management view with features to add, edit, and manage drives.
- Implemented modals for adding and editing drives with appropriate validations and options.
2025-12-05 04:11:20 -05:00

970 lines
41 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\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\View;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Paymenter\Extensions\Servers\Proxmox\Console\Commands\PollServerStatistics;
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
{
public function enabled()
{
Artisan::call('migrate', ['--path' => 'extensions/Servers/Proxmox/database/migrations', '--force' => 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.statistics', \Paymenter\Extensions\Servers\Proxmox\Livewire\Statistics::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);
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 = [])
{
$url = rtrim($this->config('host'), '/') . '/api2/json' . $url;
$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,
])->$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>]'),
],
];
}
$resourcePool = $this->request('/pools');
$poolList = [];
foreach ($resourcePool->json()['data'] as $pool) {
$poolList[] = [
'label' => $pool['poolid'],
'value' => $pool['poolid'],
];
}
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' => 'write back',
],
[
'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)
{
$oses = Location::findOrFail($product->settings()->where('key', 'location_id')->first()->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
{
return true;
}
public function requestAndWait($url, $method = 'get', $data = [], $node = null)
{
$response = $this->request($url, $method, $data);
// Check if the task is running
$task = $response->json()['data'];
sleep(1); // Give Proxmox some time to start the task
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') {
sleep(1);
$status = $this->request('/nodes/' . $node . '/tasks/' . $task . '/status')->json()['data']['status'];
}
return $status;
}
private function generateMacAddress(): string
{
$mac = [];
for ($i = 0; $i < 6; $i++) {
$mac[] = str_pad(dechex(mt_rand(0, 255)), 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();
// Create the VM
$this->requestAndWait('/nodes/' . $server->node->name . '/qemu/' . $server->os->vm_id . '/clone', 'post', [
'newid' => $server->vm_id,
'name' => $server->hostname,
'target' => $server->node->name,
'full' => 1,
'storage' => $server->node->storage_location,
'pool' => $settings['pool'],
], $server->node->name);
$this->syncServerSettings($server, $settings, $password);
// Start the server
$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'];
// 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'])) {
$diskType = 'virtio0';
} elseif (isset($disk['sata0'])) {
$diskType = 'sata0';
}
// 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;
$config['ipconfig0'] = 'ip=' . $ipv4->ip . '/24' . ',gw=' . $ipv4->gateway . ',ip6=' . $ipv6->ip . '/64' . ',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;
$config['ipconfig0'] = 'ip=' . $ipv4->ip . '/24' . ',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;
$config['ipconfig1'] = 'ip6=' . $ipv6->ip . '/64' . ',gw6=' . $ipv6->gateway;
}
}
$this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config', 'put', $config);
}
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)
{
// 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'];
$memoryNeeded = (int) $settings['memory'];
$diskNeeded = (int) $settings['disk'];
if ($location->server_placement == 'random') {
$node = $location->nodes()->inRandomOrder()->first();
} elseif ($location->server_placement == 'fill') {
// Get the nodes that have enough resources
$nodes = $location->nodes()->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 = $location->nodes()->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 = $location->nodes()->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 = $location->nodes()->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 = $location->nodes()->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)
{
// Check if user exists
try {
$this->request('/access/users/' . 'PAYMENTER_VNC_' . $server->id . '@pve', 'delete');
} catch (Exception $e) {
// Handle exception
}
$password = Str::password();
// Create user
$this->request('/access/users', 'post', [
'userid' => 'PAYMENTER_VNC_' . $server->id . '@pve',
'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' => 'PAYMENTER_VNC_' . $server->id . '@pve',
]);
return $password;
}
public function vnc(Server $server)
{
$password = $this->recreateVncUser($server);
// curl -k -d 'username=root@pam' --data-urlencode 'password=xxxxxxxxx' https://10.0.0.1:8006/api2/json/access/ticket
$response = Http::withOptions([
'verify' => $this->config('verify_ssl') ? true : false,
])->post(rtrim($this->config('host'), '/') . '/api2/json/access/ticket', [
'username' => 'PAYMENTER_VNC_' . $server->id . '@pve',
'password' => $password,
])->throw()->json()['data'];
$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 . '/qemu/' . $server->vm_id . '/vncproxy', [
'websocket' => 1,
])->json()['data'];
// Current
$paymenterDomain = rtrim(config('settings.app_url'), '/');
// Remove port and http
$paymenterDomain = preg_replace('/^https?:\/\//', '', $paymenterDomain);
$paymenterDomain = preg_replace('/:\d+$/', '', $paymenterDomain);
$parts = explode('.', $paymenterDomain);
if (count($parts) >= 2) {
$paymenterDomain = $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1];
}
setcookie('PVEAuthCookie', $response['ticket'], 0, '/', $paymenterDomain, false, true);
return rtrim($this->config('host'), '/') .
'/?console=kvm' .
'&novnc=1' .
'&node=' . $server->node->name .
'&resize=1' .
'&vmid=' . $server->vm_id .
'&path=/api2/json/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/vncwebsocket';
}
public function xterm(Server $server)
{
$password = $this->recreateVncUser($server);
// curl -k -d 'username=root@pam' --data-urlencode 'password=xxxxxxxxx' https://10.0.0.1:8006/api2/json/access/ticket
$response = Http::withOptions([
'verify' => $this->config('verify_ssl') ? true : false,
])->post(rtrim($this->config('host'), '/') . '/api2/json/access/ticket', [
'username' => 'PAYMENTER_VNC_' . $server->id . '@pve',
'password' => $password,
])->throw()->json()['data'];
$domain = rtrim($this->config('host'), '/');
// Remove port and http
$domain = preg_replace('/^https?:\/\//', '', $domain);
$domain = preg_replace('/:\d+$/', '', $domain);
// Current
$paymenterDomain = rtrim(config('settings.app_url'), '/');
// Remove port and http
$paymenterDomain = preg_replace('/^https?:\/\//', '', $paymenterDomain);
$paymenterDomain = preg_replace('/:\d+$/', '', $paymenterDomain);
$parts = explode('.', $paymenterDomain);
if (count($parts) >= 2) {
$paymenterDomain = $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1];
}
setcookie('PVEAuthCookie', $response['ticket'], 0, '/', $paymenterDomain, false, true);
return rtrim($this->config('host'), '/') .
'/?console=kvm' .
'&xtermjs=1' .
'&node=' . $server->node->name .
'&vmid=' . $server->vm_id;
}
/**
* 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']);
$node = $this->calculateNode($location, $service, $settings);
$os = $location->os()->findOrFail($settings['os']);
// 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');
}
$vm_id = $this->request('/cluster/nextid')->json()['data'];
$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();
// 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();
// Stop server
$this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/stop', 'post', [
'vmid' => $server->vm_id,
]);
$server->status = 'suspended';
$server->save();
}
/**
* 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();
$server->status = 'active';
$server->save();
}
/**
* 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();
// Kill the server
$this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/status/stop', 'post', [
'vmid' => $server->vm_id,
]);
sleep(3);
// Delete proxmox server
$this->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id, 'delete');
// Delete server
$server->delete();
return true;
}
/**
* Upgrade a server
*
* @param array $settings (product settings)
* @param array $properties (checkout options)
* @return bool
*/
public function upgradeServer(Service $service, $settings, $properties)
{
if (!Server::where('service_id', $service->id)->exists()) {
return false;
}
$server = Server::where('service_id', $service->id)->first();
// Update the server settings
$this->syncServerSettings($server, $settings, null);
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 [];
}
}