initial commit

This commit is contained in:
TheOnlyMace
2026-05-17 13:26:14 +02:00
commit 75299b723d
176 changed files with 20327 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Services\Hosting\Backups;
use App\Exceptions\Hosting\ProvisioningException;
use App\Models\Customer;
use App\Models\User;
use App\Models\VmBackup;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\Log;
class BackupService
{
public function __construct(private readonly ProxmoxClient $proxmox) {}
public function start(Customer $vm, User $user): VmBackup
{
if (! config('hosting.backups.enabled', false)) {
throw new ProvisioningException('Backups sind noch nicht aktiviert (PBS-Rechte fehlen).', step: 'backup');
}
$max = $this->maxBackupsForUser($user);
$count = VmBackup::query()
->whereHas('vm', fn ($q) => $q->where('user_id', $user->id))
->whereIn('status', ['running', 'completed'])
->count();
if ($count >= $max) {
throw new ProvisioningException("Maximal {$max} Backups erlaubt.", step: 'backup_limit');
}
$storage = config('hosting.backups.pbs_storage', 'inett-PBS');
$upid = $this->proxmox->startBackup((int) $vm->vmid, $storage);
return VmBackup::query()->create([
'customer_id' => $vm->id,
'user_id' => $user->id,
'storage' => $storage,
'volume_id' => $upid,
'status' => 'running',
]);
}
public function maxBackupsForUser(User $user): int
{
return (int) config('hosting.backups.max_per_customer', 4);
}
public function deleteAllForVm(Customer $vm): void
{
$backups = VmBackup::query()->where('customer_id', $vm->id)->get();
foreach ($backups as $backup) {
Log::info('Backup record removed', ['id' => $backup->id, 'volume' => $backup->volume_id]);
// PBS purge via API when credentials available
$backup->delete();
}
}
public function enforceRetentionForUser(User $user): void
{
$max = $this->maxBackupsForUser($user);
$backups = VmBackup::query()
->whereHas('vm', fn ($q) => $q->where('user_id', $user->id))
->where('status', 'completed')
->orderByDesc('completed_at')
->get();
foreach ($backups->slice($max) as $old) {
$old->delete();
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Services\Hosting\DTO;
use App\Models\Customer;
readonly class CustomerProvisionData
{
public function __construct(
public string $customer_name,
public ?string $domain,
public ?int $vmid = null,
public ?string $ip = null,
public ?string $public_ip = null,
public int $cpu = 2,
public int $ram = 2048,
public int $disk = 32,
public bool $behind_traefik = true,
public ?int $ip_pool_id = null,
) {}
public static function fromCustomer(Customer $customer): self
{
return new self(
customer_name: $customer->name,
domain: $customer->domain,
vmid: $customer->vmid,
ip: $customer->ip_address,
public_ip: $customer->public_ip,
cpu: $customer->cpu,
ram: $customer->ram,
disk: $customer->disk,
behind_traefik: $customer->behind_traefik,
ip_pool_id: $customer->ip_pool_id,
);
}
public static function fromArray(array $data): self
{
return new self(
customer_name: (string) $data['customer_name'],
domain: isset($data['domain']) ? (string) $data['domain'] : null,
vmid: isset($data['vmid']) ? (int) $data['vmid'] : null,
ip: isset($data['ip']) ? (string) $data['ip'] : null,
public_ip: isset($data['public_ip']) ? (string) $data['public_ip'] : null,
cpu: (int) ($data['cpu'] ?? config('hosting.defaults.cpu', 2)),
ram: (int) ($data['ram'] ?? config('hosting.defaults.ram', 2048)),
disk: (int) ($data['disk'] ?? config('hosting.defaults.disk', 32)),
behind_traefik: (bool) ($data['behind_traefik'] ?? true),
ip_pool_id: isset($data['ip_pool_id']) ? (int) $data['ip_pool_id'] : null,
);
}
public function subdomain(): string
{
if (! $this->domain) {
return '';
}
$base = config('hosting.plesk.base_domain');
$suffix = '.'.$base;
if (str_ends_with($this->domain, $suffix)) {
return substr($this->domain, 0, -strlen($suffix));
}
return explode('.', $this->domain)[0] ?? $this->customer_name;
}
public function requiresTraefik(): bool
{
return $this->behind_traefik && $this->domain !== null && $this->domain !== '';
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Hosting\Firewall;
use App\Models\Customer;
use App\Models\VmFirewallRule;
use App\Services\Hosting\Proxmox\ProxmoxClient;
class FirewallService
{
public function __construct(private readonly ProxmoxClient $proxmox) {}
public function syncToProxmox(Customer $vm): void
{
if (! $vm->vmid) {
return;
}
$rules = $vm->firewallRules()->where('is_active', true)->orderBy('sort_order')->get();
$payload = $rules->map(fn (VmFirewallRule $r) => [
'type' => $r->direction === 'out' ? 'out' : 'in',
'action' => $r->action,
'proto' => $r->protocol,
'dport' => $r->port,
'source' => $r->source,
'enable' => 1,
])->all();
if ($payload !== []) {
$this->proxmox->setFirewallRules((int) $vm->vmid, $payload);
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Services\Hosting\Health;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
class SystemHealthService
{
public function __construct(private readonly ProxmoxClient $proxmox) {}
/**
* @return array<string, array{status: string, message: string}>
*/
public function checks(): array
{
return [
'database' => $this->checkDatabase(),
'proxmox' => $this->checkProxmox(),
'traefik_config' => $this->checkTraefik(),
'queue' => $this->checkQueue(),
];
}
private function checkDatabase(): array
{
try {
DB::connection()->getPdo();
return ['status' => 'ok', 'message' => 'Datenbank erreichbar'];
} catch (\Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
}
private function checkProxmox(): array
{
try {
$this->proxmox->node();
return ['status' => 'ok', 'message' => 'Proxmox API erreichbar'];
} catch (\Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
}
private function checkTraefik(): array
{
$path = config('hosting.traefik.dynamic_config_path');
if (! $path) {
return ['status' => 'warning', 'message' => 'Traefik-Pfad nicht konfiguriert'];
}
if (! is_writable(dirname($path)) && ! file_exists($path)) {
return ['status' => 'warning', 'message' => 'Traefik-Verzeichnis nicht beschreibbar'];
}
return ['status' => 'ok', 'message' => is_file($path) ? 'Konfigurationsdatei vorhanden' : 'Wird bei erster Route angelegt'];
}
private function checkQueue(): array
{
try {
$size = DB::table('jobs')->count();
return ['status' => 'ok', 'message' => "Queue: {$size} ausstehende Jobs"];
} catch (\Throwable) {
return ['status' => 'warning', 'message' => 'Queue-Tabelle nicht verfügbar (sync driver?)'];
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services\Hosting\Iso;
use App\Exceptions\Hosting\ProvisioningException;
use App\Models\CustomerIsoUpload;
use App\Models\User;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use Illuminate\Http\UploadedFile;
class IsoUploadService
{
public function __construct(private readonly ProxmoxClient $proxmox) {}
public function upload(User $user, UploadedFile $file): CustomerIsoUpload
{
if (! config('hosting.iso_upload.enabled', true)) {
throw new ProvisioningException('ISO-Upload ist deaktiviert.', step: 'iso_upload');
}
$maxMb = (int) config('hosting.iso_upload.max_size_mb', 10240);
if ($file->getSize() > $maxMb * 1024 * 1024) {
throw new ProvisioningException("Maximale ISO-Größe: {$maxMb} MB.", step: 'iso_upload');
}
$maxPerCustomer = (int) config('hosting.iso_upload.max_per_customer', 1);
$existing = CustomerIsoUpload::query()->where('user_id', $user->id)->where('expires_at', '>', now())->count();
if ($existing >= $maxPerCustomer) {
throw new ProvisioningException('Nur eine aktive ISO pro Kunde erlaubt.', step: 'iso_upload');
}
$storage = config('hosting.proxmox.iso_storage', 'ISO');
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $file->getClientOriginalName()) ?: 'upload.iso';
$path = $file->getRealPath();
$this->proxmox->uploadIsoToStorage($storage, $safeName, $path);
$volid = "{$storage}:iso/{$safeName}";
$hours = (int) config('hosting.iso_upload.retention_hours', 48);
return CustomerIsoUpload::query()->create([
'user_id' => $user->id,
'filename' => $safeName,
'volid' => $volid,
'size_bytes' => $file->getSize(),
'expires_at' => now()->addHours($hours),
]);
}
public function purgeExpired(): int
{
$storage = config('hosting.proxmox.iso_storage', 'ISO');
$count = 0;
foreach (CustomerIsoUpload::query()->where('expires_at', '<=', now())->get() as $upload) {
try {
$this->proxmox->deleteStorageFile($storage, $upload->volid);
} catch (\Throwable) {
//
}
$upload->delete();
$count++;
}
return $count;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Services\Hosting\Metrics;
use App\Models\Customer;
use App\Models\VmMetric;
use App\Services\Hosting\Proxmox\ProxmoxClient;
class MetricsCollectorService
{
public function __construct(private readonly ProxmoxClient $proxmox) {}
public function collectAll(): int
{
$count = 0;
$vms = Customer::query()->where('status', 'active')->whereNotNull('vmid')->get();
foreach ($vms as $vm) {
try {
$raw = $this->proxmox->getVMStatus((int) $vm->vmid);
$normalized = $this->proxmox->normalizeLiveStatus($raw);
VmMetric::query()->create([
'customer_id' => $vm->id,
'cpu' => $normalized['cpu'],
'mem' => $normalized['mem'],
'maxmem' => $normalized['maxmem'],
'disk' => $normalized['disk'],
'maxdisk' => $normalized['maxdisk'],
'recorded_at' => now(),
]);
VmMetric::query()
->where('customer_id', $vm->id)
->where('recorded_at', '<', now()->subDays(7))
->delete();
$count++;
} catch (\Throwable) {
//
}
}
return $count;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services\Hosting\Notifications;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class HostingNotificationService
{
public function provisioningCompleted(Customer $vm, User $user): void
{
$this->send($user, 'VM bereit: '.$vm->name, "Ihre VM {$vm->name} wurde erfolgreich provisioniert.", [
'event' => 'provisioning.completed',
'customer_id' => $vm->id,
]);
}
public function provisioningFailed(Customer $vm, User $user, string $error): void
{
$this->send($user, 'Provisioning fehlgeschlagen: '.$vm->name, $error, [
'event' => 'provisioning.failed',
'customer_id' => $vm->id,
'error' => $error,
]);
}
public function vmDown(Customer $vm, User $user): void
{
$this->send($user, 'VM offline: '.$vm->name, "Ihre VM {$vm->name} ist nicht mehr erreichbar (Proxmox).", [
'event' => 'vm.down',
]);
}
private function send(User $user, string $subject, string $body, array $webhookPayload): void
{
if (config('hosting.plesk.mail_enabled', true)) {
try {
Mail::raw($body, fn ($m) => $m->to($user->email)->subject($subject));
} catch (\Throwable $e) {
Log::warning('Mail send failed', ['error' => $e->getMessage()]);
}
}
$url = config('hosting.notifications.webhook_url');
if ($url) {
try {
Http::timeout(10)->post($url, array_merge($webhookPayload, [
'email' => $user->email,
'subject' => $subject,
'body' => $body,
]));
} catch (\Throwable $e) {
Log::warning('Webhook failed', ['error' => $e->getMessage()]);
}
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace App\Services\Hosting\Plesk;
use App\Exceptions\Hosting\PleskException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PleskClient
{
private PendingRequest $http;
public function __construct()
{
$user = config('hosting.plesk.user');
$password = config('hosting.plesk.password');
if (empty($user) || empty($password)) {
throw new PleskException('PLESK_USER and PLESK_PASS must be configured.');
}
$verify = filter_var(config('hosting.plesk.verify_ssl', true), FILTER_VALIDATE_BOOLEAN);
$this->http = Http::baseUrl(rtrim(config('hosting.plesk.url'), '/').'/api/v2')
->withBasicAuth($user, $password)
->acceptJson()
->timeout((int) config('hosting.plesk.timeout', 30))
->when(! $verify, fn (PendingRequest $request) => $request->withoutVerifying());
}
public function createARecord(string $domain, string $subdomain, string $ip): array
{
$this->validateDomain($domain);
$this->validateSubdomain($subdomain);
$this->validateIp($ip);
$baseDomain = config('hosting.plesk.base_domain');
$zoneDomain = $this->resolveZoneDomain($domain, $baseDomain);
$host = $this->resolveHostLabel($domain, $subdomain, $baseDomain);
if ($this->findARecord($zoneDomain, $host) !== null) {
throw new PleskException(
"DNS A record already exists for {$host}.{$zoneDomain}",
step: 'plesk_dns_exists',
);
}
$targetIp = config('hosting.traefik.public_ip') ?: $ip;
Log::info('Plesk: creating A record', [
'zone' => $zoneDomain,
'host' => $host,
'ip' => $targetIp,
]);
$response = $this->http->post("/domains/{$zoneDomain}/dns/records", [
'type' => 'A',
'host' => $host === '@' ? '' : $host,
'value' => $targetIp,
]);
if ($response->failed()) {
throw new PleskException(
'Plesk create A record failed: '.$response->body(),
step: 'plesk_create',
context: ['status' => $response->status()],
code: $response->status(),
);
}
return $response->json() ?? [];
}
public function deleteARecord(string $domain, string $subdomain): void
{
$baseDomain = config('hosting.plesk.base_domain');
$zoneDomain = $this->resolveZoneDomain($domain, $baseDomain);
$host = $this->resolveHostLabel($domain, $subdomain, $baseDomain);
$record = $this->findARecord($zoneDomain, $host);
if ($record === null) {
Log::warning('Plesk: A record not found for deletion', compact('zoneDomain', 'host'));
return;
}
$recordId = $record['id'] ?? null;
if ($recordId === null) {
throw new PleskException('Plesk record id missing.', step: 'plesk_delete');
}
Log::info('Plesk: deleting A record', ['zone' => $zoneDomain, 'id' => $recordId]);
$response = $this->http->delete("/domains/{$zoneDomain}/dns/records/{$recordId}");
if ($response->failed() && $response->status() !== 404) {
throw new PleskException(
'Plesk delete A record failed: '.$response->body(),
step: 'plesk_delete',
code: $response->status(),
);
}
}
public function listRecords(string $domain): array
{
$zoneDomain = $this->resolveZoneDomain($domain, config('hosting.plesk.base_domain'));
try {
$response = $this->http->get("/domains/{$zoneDomain}/dns/records");
if ($response->failed()) {
throw new PleskException(
'Plesk list records failed: '.$response->body(),
step: 'plesk_list',
code: $response->status(),
);
}
return $response->json() ?? [];
} catch (ConnectionException $e) {
throw new PleskException(
'Plesk connection failed: '.$e->getMessage(),
step: 'plesk_connection',
previous: $e,
);
}
}
public function dnsExists(string $domain, string $subdomain): bool
{
$zoneDomain = $this->resolveZoneDomain($domain, config('hosting.plesk.base_domain'));
$host = $this->resolveHostLabel($domain, $subdomain, config('hosting.plesk.base_domain'));
return $this->findARecord($zoneDomain, $host) !== null;
}
private function findARecord(string $zoneDomain, string $host): ?array
{
$records = $this->listRecords($zoneDomain);
$items = $records['records'] ?? (is_array($records) && array_is_list($records) ? $records : []);
foreach ($items as $record) {
if (! is_array($record)) {
continue;
}
$type = strtoupper((string) ($record['type'] ?? ''));
$recordHost = (string) ($record['host'] ?? $record['name'] ?? '');
if ($type !== 'A') {
continue;
}
$normalizedHost = $recordHost === '' ? '@' : rtrim($recordHost, '.');
if ($normalizedHost === $host || $normalizedHost === "{$host}.{$zoneDomain}") {
return $record;
}
}
return null;
}
private function resolveZoneDomain(string $domain, string $baseDomain): string
{
if (str_ends_with($domain, '.'.$baseDomain) || $domain === $baseDomain) {
return $baseDomain;
}
return $domain;
}
private function resolveHostLabel(string $domain, string $subdomain, string $baseDomain): string
{
if ($subdomain !== '' && $subdomain !== '@') {
return $subdomain;
}
if ($domain === $baseDomain) {
return '@';
}
if (str_ends_with($domain, '.'.$baseDomain)) {
return substr($domain, 0, -(strlen($baseDomain) + 1));
}
return explode('.', $domain)[0] ?? $subdomain;
}
private function validateDomain(string $domain): void
{
if (! preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain)) {
throw new PleskException("Invalid domain: {$domain}", step: 'plesk_validation');
}
}
private function validateSubdomain(string $subdomain): void
{
if ($subdomain === '' || $subdomain === '@') {
return;
}
if (! preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i', $subdomain)) {
throw new PleskException("Invalid subdomain: {$subdomain}", step: 'plesk_validation');
}
}
private function validateIp(string $ip): void
{
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
throw new PleskException("Invalid IP: {$ip}", step: 'plesk_validation');
}
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Services\Hosting\Provisioning;
use App\Models\Customer;
use App\Models\User;
use App\Models\VmActivityLog;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\Log;
class DeprovisionService
{
public function __construct(
private readonly ProvisioningService $provisioning,
private readonly ProxmoxClient $proxmox,
private readonly VmidReservationService $vmidReservation,
) {}
/**
* Endgültige Löschung (WHMCS Terminate / Kunde nicht verlängert).
*/
public function terminatePermanently(Customer $customer, ?User $actor = null): void
{
Log::info('Permanent deprovision started', ['customer_id' => $customer->id]);
$this->deleteBackups($customer);
$customerId = $customer->id;
$vmid = $customer->vmid;
if ($actor) {
VmActivityLog::query()->create([
'customer_id' => $customerId,
'user_id' => $actor->id,
'action' => 'terminate.permanent',
'status' => 'success',
]);
}
$this->provisioning->deprovision($customer);
if ($vmid) {
$this->vmidReservation->scheduleRelease((int) $vmid, $customer);
}
$customer->devices()->delete();
$customer->delete();
}
/**
* VM entfernen, Kundeneintrag bleibt (Support-Fall).
*/
public function removeVmOnly(Customer $customer, ?User $actor = null): void
{
$this->provisioning->deprovision($customer);
if ($customer->vmid) {
$this->vmidReservation->scheduleRelease((int) $customer->vmid, $customer);
}
$customer->update([
'status' => 'failed',
'provisioning_step' => 'deprovisioned',
'vmid' => null,
]);
}
private function deleteBackups(Customer $customer): void
{
if (! $customer->vmid) {
return;
}
if (! config('hosting.backups.enabled', false)) {
Log::info('Backup deletion skipped (PBS not enabled)', ['vmid' => $customer->vmid]);
return;
}
// PBS-Integration folgt in Phase 2, wenn API-Rechte auf inett-PBS vorhanden
Log::warning('PBS backup purge pending implementation', [
'vmid' => $customer->vmid,
'storage' => config('hosting.backups.pbs_storage'),
]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Services\Hosting\Provisioning;
use App\Enums\IpPoolType;
use App\Exceptions\Hosting\ProvisioningException;
use App\Models\Customer;
use App\Models\IpPool;
use Illuminate\Support\Facades\DB;
class IpAddressAllocator
{
public function allocateFromPool(?IpPool $pool = null, ?string $preferred = null): string
{
$pool ??= $this->defaultPrivatePool();
return $this->allocate($pool, $preferred);
}
public function allocatePublicIp(?IpPool $pool = null, ?string $preferred = null): string
{
$pool ??= IpPool::query()
->where('type', IpPoolType::Public)
->where('is_active', true)
->first() ?? $this->bootstrapPublicPool();
if ($pool->type !== IpPoolType::Public) {
throw new ProvisioningException('Selected pool is not a public IP pool.', step: 'ip_allocation');
}
return $this->allocate($pool, $preferred, 'public_ip');
}
public function allocate(IpPool $pool, ?string $preferred = null, string $column = 'ip_address'): string
{
return DB::transaction(function () use ($pool, $preferred, $column) {
IpPool::query()->whereKey($pool->id)->lockForUpdate()->first();
if ($preferred !== null) {
if (! $pool->containsIp($preferred)) {
throw new ProvisioningException(
"IP {$preferred} is outside pool {$pool->name}.",
step: 'ip_allocation',
);
}
if ($this->isIpUsed($preferred, $pool, $column)) {
throw new ProvisioningException(
"IP address {$preferred} is already in use.",
step: 'ip_allocation',
);
}
return $preferred;
}
$start = ip2long($pool->start_ip);
$end = ip2long($pool->end_ip);
if ($start === false || $end === false || $start > $end) {
throw new ProvisioningException('Invalid IP pool range.', step: 'ip_allocation');
}
$used = $this->usedIpsInPool($pool, $column);
for ($long = $start; $long <= $end; $long++) {
if (! isset($used[$long])) {
return long2ip($long);
}
}
throw new ProvisioningException("No free IPs in pool {$pool->name}.", step: 'ip_allocation');
}, 3);
}
private function defaultPrivatePool(): IpPool
{
$pool = IpPool::query()
->where('type', IpPoolType::Private)
->where('is_active', true)
->orderBy('id')
->first();
if ($pool) {
return $pool;
}
return $this->bootstrapPoolFromConfig(IpPoolType::Private);
}
private function bootstrapPublicPool(): IpPool
{
return IpPool::query()->firstOrCreate(
['name' => 'Öffentlich 185.45.149.x', 'type' => IpPoolType::Public],
[
'start_ip' => config('hosting.public_network.ip_pool_start'),
'end_ip' => config('hosting.public_network.ip_pool_end'),
'gateway' => config('hosting.public_network.gateway'),
'cidr' => config('hosting.public_network.cidr'),
'is_active' => true,
],
);
}
private function bootstrapPoolFromConfig(IpPoolType $type): IpPool
{
return IpPool::query()->firstOrCreate(
['name' => 'Privat 10.32.0.0/24', 'type' => IpPoolType::Private],
[
'start_ip' => config('hosting.network.ip_pool_start'),
'end_ip' => config('hosting.network.ip_pool_end'),
'gateway' => config('hosting.network.gateway'),
'cidr' => config('hosting.network.cidr'),
'is_active' => true,
],
);
}
private function usedIpsInPool(IpPool $pool, string $column): array
{
$query = Customer::query()
->where('ip_pool_id', $pool->id)
->whereIn('status', ['pending', 'active']);
if ($column === 'public_ip') {
return $query->whereNotNull('public_ip')
->pluck('public_ip')
->merge(
Customer::query()
->whereIn('status', ['pending', 'active'])
->whereNotNull('public_ip')
->pluck('public_ip')
)
->unique()
->map(fn (string $ip) => ip2long($ip))
->filter()
->flip()
->all();
}
return $query->whereNotNull('ip_address')
->pluck('ip_address')
->map(fn (string $ip) => ip2long($ip))
->filter()
->flip()
->all();
}
private function isIpUsed(string $ip, IpPool $pool, string $column): bool
{
$check = Customer::query()
->whereIn('status', ['pending', 'active']);
if ($column === 'public_ip') {
return (clone $check)->where('public_ip', $ip)->exists()
|| (clone $check)->where('ip_address', $ip)->exists();
}
return (clone $check)->where('ip_address', $ip)->exists()
|| (clone $check)->where('public_ip', $ip)->exists();
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Hosting\Provisioning;
use App\Services\Hosting\Plesk\PleskClient;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use App\Services\Hosting\Traefik\TraefikGenerator;
use Illuminate\Support\Facades\Log;
class ProvisioningRollback
{
private array $completed = [];
public function __construct(
private readonly ProxmoxClient $proxmox,
private readonly PleskClient $plesk,
private readonly TraefikGenerator $traefik,
) {}
public function mark(string $step, array $context = []): void
{
$this->completed[] = ['step' => $step, 'context' => $context];
}
public function execute(): void
{
Log::warning('Provisioning rollback started', ['steps' => count($this->completed)]);
foreach (array_reverse($this->completed) as $entry) {
try {
match ($entry['step']) {
'traefik_route' => $this->rollbackTraefik($entry['context']),
'plesk_dns' => $this->rollbackDns($entry['context']),
'proxmox_vm_started', 'proxmox_vm_created' => $this->rollbackVm($entry['context']),
default => null,
};
} catch (\Throwable $e) {
Log::error('Rollback step failed', [
'step' => $entry['step'],
'error' => $e->getMessage(),
]);
}
}
}
private function rollbackTraefik(array $context): void
{
if (! empty($context['domain'])) {
$this->traefik->removeCustomerRoute($context['domain']);
Log::info('Rollback: Traefik route removed', $context);
}
}
private function rollbackDns(array $context): void
{
if (! empty($context['domain']) && isset($context['subdomain'])) {
$this->plesk->deleteARecord($context['domain'], $context['subdomain']);
Log::info('Rollback: Plesk DNS removed', $context);
}
}
private function rollbackVm(array $context): void
{
if (! empty($context['vmid'])) {
$this->proxmox->deleteVM((int) $context['vmid']);
Log::info('Rollback: Proxmox VM deleted', $context);
}
}
}

View File

@@ -0,0 +1,262 @@
<?php
namespace App\Services\Hosting\Provisioning;
use App\Exceptions\Hosting\ProvisioningException;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\VmTemplate;
use App\Services\Hosting\DTO\CustomerProvisionData;
use App\Services\Hosting\Plesk\PleskClient;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use App\Services\Hosting\Traefik\TraefikGenerator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProvisioningService
{
public function __construct(
private readonly ProxmoxClient $proxmox,
private readonly PleskClient $plesk,
private readonly TraefikGenerator $traefik,
private readonly IpAddressAllocator $ipAllocator,
private readonly VmidReservationService $vmidReservation,
) {}
public function provision(Customer $customer, CustomerProvisionData $data): Customer
{
$rollback = new ProvisioningRollback($this->proxmox, $this->plesk, $this->traefik);
try {
return DB::transaction(function () use ($customer, $data, $rollback) {
$this->updateStep($customer, 'reserving_vmid', 'pending');
$vmid = $data->vmid ?? $customer->vmid ?? $this->reserveVmid($customer);
$customer->update(['vmid' => $vmid]);
$this->vmidReservation->activate($vmid, $customer);
$pool = $this->resolveIpPool($customer);
$gateway = $pool->gateway ?? config('hosting.network.gateway');
$cidr = $pool->cidr ?? (int) config('hosting.network.cidr');
$this->updateStep($customer, 'allocating_ip');
$ip = $data->ip ?? $this->ipAllocator->allocateFromPool($pool);
$customer->update(['ip_address' => $ip, 'ip_pool_id' => $pool->id]);
$publicIp = null;
if (! $data->behind_traefik) {
$publicIp = $data->public_ip ?? $this->ipAllocator->allocatePublicIp();
$customer->update(['public_ip' => $publicIp]);
}
$ipConfig0 = $this->proxmox->buildIpConfig($ip, $gateway, $cidr);
$ipConfig1 = null;
if ($publicIp) {
$publicPool = IpPool::query()->where('type', 'public')->where('is_active', true)->first();
$ipConfig1 = $this->proxmox->buildIpConfig(
$publicIp,
$publicPool?->gateway,
$publicPool?->cidr ?? 32,
);
}
$vmName = $this->vmName($data->customer_name, $vmid);
$this->updateStep($customer, 'creating_vm');
$templateVmid = $this->resolveTemplateVmid($customer);
$this->proxmox->createVM(
vmid: $vmid,
name: $vmName,
cpu: $data->cpu,
ramMb: $data->ram,
diskGb: $data->disk,
ipConfig: $ipConfig0,
templateVmid: $templateVmid,
);
if ($ipConfig1) {
$this->proxmox->setCloudInitIps($vmid, $ipConfig0, $ipConfig1, $vmName);
}
$rollback->mark('proxmox_vm_created', ['vmid' => $vmid]);
$customer->load('devices');
if ($customer->devices->isNotEmpty()) {
$this->updateStep($customer, 'applying_devices');
$this->proxmox->applyDevices($vmid, $customer->devices);
}
$this->updateStep($customer, 'starting_vm');
$this->proxmox->startVM($vmid);
$rollback->mark('proxmox_vm_started', ['vmid' => $vmid]);
if ($customer->attached_iso) {
$this->updateStep($customer, 'mounting_iso');
$this->proxmox->mountIso($vmid, $customer->attached_iso);
}
if ($data->requiresTraefik()) {
$subdomain = $data->subdomain();
$this->updateStep($customer, 'creating_dns');
$this->plesk->createARecord($data->domain, $subdomain, $ip);
$rollback->mark('plesk_dns', [
'domain' => $data->domain,
'subdomain' => $subdomain,
]);
$this->updateStep($customer, 'configuring_traefik');
$this->traefik->addCustomerRoute($data->domain, $ip);
$rollback->mark('traefik_route', ['domain' => $data->domain]);
$this->updateStep($customer, 'reloading_traefik');
$this->traefik->reload();
}
try {
$live = $this->proxmox->normalizeLiveStatus($this->proxmox->getVMStatus($vmid));
$customer->update([
'status' => 'active',
'provisioning_step' => 'completed',
'error_message' => null,
'proxmox_status' => $live['status'],
'proxmox_uptime' => $live['uptime'],
'proxmox_status_at' => now(),
]);
} catch (\Throwable) {
$customer->update([
'status' => 'active',
'provisioning_step' => 'completed',
'error_message' => null,
]);
}
Log::info('Provisioning completed', [
'customer_id' => $customer->id,
'vmid' => $vmid,
'domain' => $data->domain,
'ip' => $ip,
'public_ip' => $publicIp,
]);
return $customer->fresh();
});
} catch (\Throwable $e) {
Log::error('Provisioning failed', [
'customer_id' => $customer->id,
'step' => $customer->provisioning_step,
'error' => $e->getMessage(),
]);
$rollback->execute();
$customer->update([
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
if ($e instanceof ProvisioningException) {
throw $e;
}
throw new ProvisioningException(
$e->getMessage(),
step: $customer->provisioning_step,
previous: $e,
);
}
}
public function deprovision(Customer $customer): void
{
if ($customer->domain && $customer->behind_traefik) {
try {
$this->traefik->removeCustomerRoute($customer->domain);
$this->traefik->reload();
} catch (\Throwable $e) {
Log::warning('Deprovision: Traefik cleanup failed', ['error' => $e->getMessage()]);
}
try {
$data = CustomerProvisionData::fromCustomer($customer);
$this->plesk->deleteARecord($customer->domain, $data->subdomain());
} catch (\Throwable $e) {
Log::warning('Deprovision: DNS cleanup failed', ['error' => $e->getMessage()]);
}
}
if ($customer->vmid) {
$this->proxmox->deleteVM((int) $customer->vmid);
}
$customer->update([
'status' => 'failed',
'provisioning_step' => 'deprovisioned',
'error_message' => null,
]);
}
private function resolveIpPool(Customer $customer): IpPool
{
if ($customer->ip_pool_id) {
$pool = IpPool::query()->find($customer->ip_pool_id);
if ($pool && $pool->is_active) {
return $pool;
}
}
return IpPool::query()
->where('type', 'private')
->where('is_active', true)
->orderBy('id')
->firstOrFail();
}
private function reserveVmid(Customer $customer): int
{
if ($customer->vmid) {
$vmid = (int) $customer->vmid;
if ($this->vmidReservation->isVmidBlocked($vmid)) {
return $vmid;
}
}
$vmid = $this->vmidReservation->reserveForCustomer($customer);
if ($this->proxmox->vmExists($vmid)) {
throw new ProvisioningException(
"VMID {$vmid} already exists in Proxmox.",
step: 'reserving_vmid',
);
}
return $vmid;
}
private function vmName(string $customerName, int $vmid): string
{
$slug = preg_replace('/[^a-z0-9-]/', '-', strtolower($customerName)) ?: 'customer';
return substr("host-{$slug}-{$vmid}", 0, 63);
}
private function resolveTemplateVmid(Customer $customer): ?int
{
if ($customer->provision_mode === 'empty') {
return null;
}
$fromDb = VmTemplate::query()->where('is_active', true)->orderBy('id')->value('proxmox_template_vmid');
$fromEnv = config('hosting.proxmox.template_vmid');
return $fromDb ? (int) $fromDb : ($fromEnv ? (int) $fromEnv : null);
}
private function updateStep(Customer $customer, string $step, ?string $status = null): void
{
$payload = ['provisioning_step' => $step];
if ($status !== null) {
$payload['status'] = $status;
}
$customer->update($payload);
Log::info('Provisioning step', ['customer_id' => $customer->id, 'step' => $step]);
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Services\Hosting\Provisioning;
use App\Exceptions\Hosting\ProvisioningException;
use App\Models\Customer;
use App\Models\VmidReservation;
use Illuminate\Support\Facades\DB;
class VmidReservationService
{
public function reserveForCustomer(Customer $customer): int
{
return DB::transaction(function () use ($customer) {
$vmid = $this->findNextAvailableVmid();
VmidReservation::query()->create([
'vmid' => $vmid,
'customer_id' => $customer->id,
'status' => 'reserved',
]);
return $vmid;
});
}
public function activate(int $vmid, Customer $customer): void
{
VmidReservation::query()
->where('vmid', $vmid)
->where('customer_id', $customer->id)
->update([
'status' => 'active',
'release_at' => null,
]);
}
public function scheduleRelease(int $vmid, ?Customer $customer = null): void
{
$hours = (int) config('hosting.vmid.release_after_hours', 48);
$query = VmidReservation::query()->where('vmid', $vmid);
if ($customer) {
$query->where('customer_id', $customer->id);
}
$query->update([
'status' => 'pending_release',
'release_at' => now()->addHours($hours),
]);
}
public function releaseDue(): int
{
$count = 0;
$due = VmidReservation::query()
->where('status', 'pending_release')
->where('release_at', '<=', now())
->get();
foreach ($due as $reservation) {
$reservation->update([
'status' => 'released',
'released_at' => now(),
]);
$count++;
}
return $count;
}
public function isVmidBlocked(int $vmid): bool
{
return VmidReservation::query()
->where('vmid', $vmid)
->whereIn('status', ['reserved', 'active', 'pending_release'])
->exists();
}
private function findNextAvailableVmid(): int
{
$start = (int) config('hosting.vmid.range_start', 2000);
$end = (int) config('hosting.vmid.range_end', 2999);
$blocked = VmidReservation::query()
->whereIn('status', ['reserved', 'active', 'pending_release'])
->pluck('vmid')
->flip()
->all();
for ($vmid = $start; $vmid <= $end; $vmid++) {
if (! isset($blocked[$vmid])) {
return $vmid;
}
}
throw new ProvisioningException(
"No free VMID in range {$start}-{$end}.",
step: 'reserving_vmid',
);
}
}

View File

@@ -0,0 +1,591 @@
<?php
namespace App\Services\Hosting\Proxmox;
use App\Exceptions\Hosting\ProxmoxException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ProxmoxClient
{
private ?PendingRequest $http = null;
private function http(): PendingRequest
{
if ($this->http !== null) {
return $this->http;
}
$token = config('hosting.proxmox.token');
if (empty($token)) {
throw new ProxmoxException('PROXMOX_TOKEN is not configured.');
}
$verify = filter_var(config('hosting.proxmox.verify_ssl'), FILTER_VALIDATE_BOOLEAN);
return $this->http = Http::baseUrl(rtrim(config('hosting.proxmox.url'), '/').'/api2/json')
->withHeaders([
'Authorization' => str_starts_with($token, 'PVEAPIToken=') ? $token : 'PVEAPIToken='.$token,
])
->acceptJson()
->timeout((int) config('hosting.proxmox.timeout', 120))
->when(! $verify, fn (PendingRequest $request) => $request->withoutVerifying());
}
public function getNextVmid(): int
{
$response = $this->request('GET', '/cluster/nextid');
return (int) $response['data'];
}
public function vmExists(int $vmid): bool
{
try {
$this->getVMStatus($vmid);
return true;
} catch (ProxmoxException $e) {
if (str_contains($e->getMessage(), 'does not exist') || $e->getCode() === 404) {
return false;
}
throw $e;
}
}
public function createVM(
int $vmid,
string $name,
int $cpu,
int $ramMb,
int $diskGb,
string $ipConfig,
?int $templateVmid = null,
): void {
if ($this->vmExists($vmid)) {
throw new ProxmoxException("VM {$vmid} already exists.", step: 'proxmox_create');
}
$node = config('hosting.proxmox.node');
$storage = config('hosting.proxmox.storage');
$bridge = config('hosting.proxmox.bridge');
$templateVmid ??= config('hosting.proxmox.template_vmid') ? (int) config('hosting.proxmox.template_vmid') : null;
Log::info('Proxmox: creating VM', compact('vmid', 'name', 'cpu', 'ramMb', 'diskGb'));
if ($templateVmid) {
$this->cloneFromTemplateVmid((int) $templateVmid, $vmid, $name);
$this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb);
$this->configureCloudInit($vmid, $ipConfig, $name);
} else {
$this->createVmFromScratch($vmid, $name, $cpu, $ramMb, $diskGb, $ipConfig, $node, $storage, $bridge);
}
}
public function setCloudInitIps(int $vmid, string $ipconfig0, ?string $ipconfig1, string $name): void
{
$this->configureCloudInit($vmid, $ipconfig0, $name, $ipconfig1);
}
public function node(): string
{
return (string) config('hosting.proxmox.node');
}
public function startVM(int $vmid): void
{
$this->powerAction($vmid, 'start');
}
public function shutdownVM(int $vmid, int $timeout = 60): void
{
$this->powerAction($vmid, 'shutdown', ['timeout' => $timeout]);
}
public function stopVM(int $vmid): void
{
$this->powerAction($vmid, 'stop');
}
public function rebootVM(int $vmid, int $timeout = 60): void
{
$this->powerAction($vmid, 'reboot', ['timeout' => $timeout]);
}
public function resetVM(int $vmid): void
{
$this->powerAction($vmid, 'reset');
}
public function powerAction(int $vmid, string $action, array $params = []): void
{
$node = $this->node();
Log::info('Proxmox: power action', ['vmid' => $vmid, 'action' => $action]);
$this->request('POST', "/nodes/{$node}/qemu/{$vmid}/status/{$action}", $params);
}
public function getVmConfig(int $vmid): array
{
$node = $this->node();
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/config");
return $response['data'] ?? [];
}
/**
* @return array<int, array{volid: string, label: string, size: int}>
*/
public function listIsos(?string $storage = null): array
{
$storage ??= config('hosting.proxmox.iso_storage', 'local');
$node = $this->node();
$response = $this->request('GET', "/nodes/{$node}/storage/{$storage}/content", [
'content' => 'iso',
]);
$items = $response['data'] ?? [];
return collect($items)
->filter(fn ($item) => is_array($item) && ($item['content'] ?? '') === 'iso')
->map(fn (array $item) => [
'volid' => (string) ($item['volid'] ?? ''),
'label' => (string) ($item['volid'] ?? $item['name'] ?? 'unknown'),
'size' => (int) ($item['size'] ?? 0),
])
->filter(fn (array $item) => $item['volid'] !== '')
->values()
->all();
}
public function mountIso(int $vmid, string $isoVolid, ?string $device = null): void
{
$device ??= config('hosting.proxmox.iso_device', 'ide2');
$node = $this->node();
if (! str_contains($isoVolid, ':')) {
$storage = config('hosting.proxmox.iso_storage', 'local');
$isoVolid = "{$storage}:iso/{$isoVolid}";
}
Log::info('Proxmox: mounting ISO', ['vmid' => $vmid, 'iso' => $isoVolid, 'device' => $device]);
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
$device => "{$isoVolid},media=cdrom",
'boot' => "order={$device};scsi0",
]);
}
public function unmountIso(int $vmid, ?string $device = null): void
{
$device ??= config('hosting.proxmox.iso_device', 'ide2');
$node = $this->node();
Log::info('Proxmox: unmounting ISO', ['vmid' => $vmid, 'device' => $device]);
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
'delete' => $device,
'boot' => 'order=scsi0',
]);
}
/**
* @return array{port: string, ticket: string, cert: ?string, node: string, vmid: int}
*/
public function createVncProxy(int $vmid): array
{
$node = $this->node();
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/vncproxy", [
'websocket' => 1,
]);
$data = $response['data'] ?? [];
return [
'port' => (string) ($data['port'] ?? ''),
'ticket' => (string) ($data['ticket'] ?? ''),
'cert' => isset($data['cert']) ? (string) $data['cert'] : null,
'node' => $node,
'vmid' => $vmid,
];
}
public function buildVncWebSocketUrl(int $vmid, string $port, string $ticket): string
{
$base = rtrim(config('hosting.proxmox.console_ws_url') ?: config('hosting.proxmox.url'), '/');
$parsed = parse_url($base);
$scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'wss' : 'ws';
$host = $parsed['host'] ?? 'localhost';
$portNum = $parsed['port'] ?? (($parsed['scheme'] ?? 'https') === 'https' ? 8006 : 80);
$node = $this->node();
return sprintf(
'%s://%s:%s/api2/json/nodes/%s/qemu/%d/vncwebsocket?port=%s&vncticket=%s',
$scheme,
$host,
$portNum,
$node,
$vmid,
urlencode($port),
urlencode($ticket),
);
}
public function normalizeLiveStatus(array $status): array
{
return [
'status' => (string) ($status['status'] ?? 'unknown'),
'uptime' => (int) ($status['uptime'] ?? 0),
'cpu' => (float) ($status['cpu'] ?? 0),
'mem' => (int) ($status['mem'] ?? 0),
'maxmem' => (int) ($status['maxmem'] ?? 0),
'disk' => (int) ($status['disk'] ?? 0),
'maxdisk' => (int) ($status['maxdisk'] ?? 0),
];
}
public function waitForTask(string $upid, int $maxSeconds = 600): void
{
$node = $this->node();
$deadline = time() + $maxSeconds;
while (time() < $deadline) {
$response = $this->request('GET', '/nodes/'.$node.'/tasks/'.rawurlencode($upid).'/status');
$status = $response['data']['status'] ?? '';
if ($status === 'stopped') {
$exit = $response['data']['exitstatus'] ?? 'OK';
if ($exit !== 'OK') {
throw new ProxmoxException('Proxmox task failed: '.$exit, step: 'proxmox_task');
}
return;
}
sleep(2);
}
throw new ProxmoxException('Proxmox task timeout: '.$upid, step: 'proxmox_task');
}
public function createSnapshot(int $vmid, string $name): void
{
$node = $this->node();
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot", [
'snapname' => $name,
]);
if (! empty($response['data'])) {
$this->waitForTask($response['data']);
}
}
public function rollbackSnapshot(int $vmid, string $name): void
{
$node = $this->node();
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}/rollback");
if (! empty($response['data'])) {
$this->waitForTask($response['data']);
}
}
public function deleteSnapshot(int $vmid, string $name): void
{
$node = $this->node();
$this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}");
}
/**
* @return array<int, array{name: string, description: string}>
*/
public function listSnapshots(int $vmid): array
{
$node = $this->node();
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/snapshot");
return collect($response['data'] ?? [])
->filter(fn ($item) => is_array($item))
->map(fn (array $item) => [
'name' => (string) ($item['name'] ?? ''),
'description' => (string) ($item['description'] ?? ''),
])
->filter(fn ($item) => $item['name'] !== '')
->values()
->all();
}
public function startBackup(int $vmid, string $storage): string
{
$node = $this->node();
$response = $this->request('POST', "/nodes/{$node}/vzdump", [
'vmid' => $vmid,
'storage' => $storage,
'mode' => 'snapshot',
'compress' => 'zstd',
]);
return (string) ($response['data'] ?? '');
}
public function getFirewallRules(int $vmid): array
{
$node = $this->node();
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/firewall/rules");
return $response['data'] ?? [];
}
public function setFirewallRules(int $vmid, array $rules): void
{
$node = $this->node();
// Replace all rules: delete existing then add - simplified: enable firewall + pos rules via PUT pos
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/firewall/options", ['enable' => 1]);
foreach ($rules as $pos => $rule) {
$this->request('POST', "/nodes/{$node}/qemu/{$vmid}/firewall/rules", array_merge($rule, ['pos' => $pos]));
}
}
public function uploadIsoToStorage(string $storage, string $filename, string $localPath): void
{
$node = $this->node();
$client = $this->http();
$response = $client->attach(
'content',
file_get_contents($localPath),
$filename
)->post("/nodes/{$node}/storage/{$storage}/upload", [
'content' => 'iso',
'filename' => $filename,
]);
if ($response->failed()) {
throw new ProxmoxException('ISO upload failed: '.$response->body(), step: 'proxmox_upload');
}
}
public function deleteStorageFile(string $storage, string $volid): void
{
$node = $this->node();
$this->request('DELETE', "/nodes/{$node}/storage/{$storage}/content/".urlencode($volid));
}
public function cloneFromTemplateVmid(int $templateVmid, int $newVmid, string $name): void
{
$node = $this->node();
$response = $this->request('POST', "/nodes/{$node}/qemu/{$templateVmid}/clone", [
'newid' => $newVmid,
'name' => $name,
'full' => 1,
'target' => $node,
]);
if (! empty($response['data'])) {
$this->waitForTask($response['data']);
}
}
public function deleteVM(int $vmid): void
{
if (! $this->vmExists($vmid)) {
Log::warning('Proxmox: VM already absent, skipping delete', ['vmid' => $vmid]);
return;
}
try {
$status = $this->getVMStatus($vmid);
if (($status['status'] ?? '') === 'running') {
$this->stopVM($vmid);
$this->waitUntilStopped($vmid);
}
} catch (ProxmoxException) {
// continue with delete attempt
}
$node = config('hosting.proxmox.node');
Log::info('Proxmox: deleting VM', ['vmid' => $vmid]);
$this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}");
}
public function getVMStatus(int $vmid): array
{
$node = config('hosting.proxmox.node');
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/status/current");
return $response['data'] ?? [];
}
public function buildIpConfig(string $ip, ?string $gateway = null, ?int $cidr = null): string
{
$cidr ??= (int) config('hosting.network.cidr');
$gateway ??= config('hosting.network.gateway');
return "ip={$ip}/{$cidr},gw={$gateway}";
}
/**
* @param iterable<int, \App\Models\VmDevice> $devices
*/
public function applyDevices(int $vmid, iterable $devices): void
{
$node = config('hosting.proxmox.node');
$storage = config('hosting.proxmox.storage');
$params = [];
foreach ($devices as $device) {
$config = $device->config ?? [];
$slot = $device->slot;
match ($device->type) {
\App\Models\VmDevice::TYPE_DISK => $params[$slot ?? 'scsi1'] = sprintf(
'%s:%d',
$config['storage'] ?? $storage,
(int) ($config['size_gb'] ?? 10),
),
\App\Models\VmDevice::TYPE_NETWORK => $params[$slot ?? 'net1'] = sprintf(
'%s,bridge=%s',
$config['model'] ?? 'virtio',
$config['bridge'] ?? config('hosting.proxmox.bridge'),
),
\App\Models\VmDevice::TYPE_USB => $params[$slot ?? 'usb0'] = $config['host_id'] ?? 'host=0',
\App\Models\VmDevice::TYPE_PCI => $params[$slot ?? 'hostpci0'] = $config['address'] ?? '',
default => null,
};
}
$params = array_filter($params, fn ($v) => $v !== '' && $v !== null);
if ($params === []) {
return;
}
Log::info('Proxmox: applying VM devices', ['vmid' => $vmid, 'devices' => array_keys($params)]);
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params);
}
public function updateVmResources(int $vmid, int $cpu, int $ramMb, int $diskGb): void
{
$this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb);
}
private function resizeVmIfNeeded(int $vmid, int $cpu, int $ramMb, int $diskGb): void
{
$node = config('hosting.proxmox.node');
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
'cores' => $cpu,
'memory' => $ramMb,
]);
// Disk resize depends on storage layout; best-effort grow on scsi0
try {
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/resize", [
'disk' => 'scsi0',
'size' => $diskGb.'G',
]);
} catch (ProxmoxException $e) {
Log::warning('Proxmox: disk resize skipped', ['vmid' => $vmid, 'error' => $e->getMessage()]);
}
}
private function configureCloudInit(int $vmid, string $ipConfig, string $name, ?string $ipConfig1 = null): void
{
$node = config('hosting.proxmox.node');
$bridge = config('hosting.proxmox.bridge');
$params = [
'name' => $name,
'ipconfig0' => $ipConfig,
'net0' => "virtio,bridge={$bridge}",
];
if ($ipConfig1) {
$publicBridge = config('hosting.proxmox.public_bridge', 'vmbr1');
$params['ipconfig1'] = $ipConfig1;
$params['net1'] = "virtio,bridge={$publicBridge}";
}
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params);
}
private function createVmFromScratch(
int $vmid,
string $name,
int $cpu,
int $ramMb,
int $diskGb,
string $ipConfig,
string $node,
string $storage,
string $bridge,
): void {
$this->request('POST', "/nodes/{$node}/qemu", [
'vmid' => $vmid,
'name' => $name,
'cores' => $cpu,
'memory' => $ramMb,
'ostype' => 'l26',
'scsihw' => 'virtio-scsi-pci',
'scsi0' => "{$storage}:{$diskGb}",
'ide2' => "{$storage}:cloudinit",
'boot' => 'order=scsi0',
'net0' => "virtio,bridge={$bridge}",
'ipconfig0' => $ipConfig,
'agent' => 1,
]);
}
private function waitUntilStopped(int $vmid, int $maxSeconds = 60): void
{
$deadline = time() + $maxSeconds;
while (time() < $deadline) {
$status = $this->getVMStatus($vmid);
if (($status['status'] ?? '') !== 'running') {
return;
}
sleep(2);
}
throw new ProxmoxException("Timeout waiting for VM {$vmid} to stop.", step: 'proxmox_stop');
}
private function request(string $method, string $uri, array $data = []): array
{
try {
$client = $this->http();
$response = match (strtoupper($method)) {
'GET' => $client->get($uri, $data),
'POST' => $client->asForm()->post($uri, $data),
'PUT' => $client->asForm()->put($uri, $data),
'DELETE' => $client->delete($uri, $data),
default => throw new ProxmoxException("Unsupported HTTP method: {$method}"),
};
if ($response->failed()) {
$message = $response->json('errors') ?? $response->json('message') ?? $response->body();
throw new ProxmoxException(
'Proxmox API error: '.(is_string($message) ? $message : json_encode($message)),
step: 'proxmox_api',
context: ['uri' => $uri, 'status' => $response->status()],
code: $response->status(),
);
}
return $response->json() ?? [];
} catch (ConnectionException $e) {
throw new ProxmoxException(
'Proxmox connection timeout or unreachable: '.$e->getMessage(),
step: 'proxmox_connection',
previous: $e,
);
} catch (RequestException $e) {
throw new ProxmoxException(
'Proxmox HTTP error: '.$e->getMessage(),
step: 'proxmox_http',
previous: $e,
);
}
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Services\Hosting\Proxmox;
use App\Enums\VmPowerAction;
use App\Exceptions\Hosting\ProxmoxException;
use App\Exceptions\Hosting\ProvisioningException;
use App\Models\Customer;
use App\Models\User;
use App\Models\VmActivityLog;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class VmManagementService
{
public function __construct(
private readonly ProxmoxClient $proxmox,
) {}
public function assertManageable(Customer $vm): void
{
if (! $vm->vmid) {
throw new ProvisioningException('VM ist noch nicht in Proxmox provisioniert.', step: 'vm_not_ready');
}
if ($vm->status !== 'active') {
throw new ProvisioningException('VM ist nicht aktiv (Status: '.$vm->status.').', step: 'vm_not_active');
}
}
public function refreshLiveStatus(Customer $vm): array
{
$this->assertManageable($vm);
$raw = $this->proxmox->getVMStatus((int) $vm->vmid);
$normalized = $this->proxmox->normalizeLiveStatus($raw);
$vm->update([
'proxmox_status' => $normalized['status'],
'proxmox_uptime' => $normalized['uptime'],
'proxmox_status_at' => now(),
]);
return $normalized;
}
public function power(Customer $vm, VmPowerAction $action, User $user): void
{
$this->assertManageable($vm);
$live = $this->refreshLiveStatus($vm);
$isRunning = ($live['status'] ?? '') === 'running';
if ($action->requiresRunning() && ! $isRunning) {
throw new ProxmoxException('VM läuft nicht Aktion nicht möglich.', step: 'power_state');
}
if ($action->requiresStopped() && $isRunning) {
throw new ProxmoxException('VM läuft bereits.', step: 'power_state');
}
try {
match ($action) {
VmPowerAction::Start => $this->proxmox->startVM((int) $vm->vmid),
VmPowerAction::Shutdown => $this->proxmox->shutdownVM((int) $vm->vmid),
VmPowerAction::Stop => $this->proxmox->stopVM((int) $vm->vmid),
VmPowerAction::Reboot => $this->proxmox->rebootVM((int) $vm->vmid),
VmPowerAction::Reset => $this->proxmox->resetVM((int) $vm->vmid),
};
$this->log($vm, $user, 'power.'.$action->value, 'success');
$this->refreshLiveStatus($vm);
} catch (\Throwable $e) {
$this->log($vm, $user, 'power.'.$action->value, 'failed', $e->getMessage());
throw $e;
}
}
public function mountIso(Customer $vm, string $isoVolid, User $user): void
{
$this->assertManageable($vm);
$available = collect($this->proxmox->listIsos())->pluck('volid');
if (! $available->contains($isoVolid)) {
throw new ProxmoxException('ISO nicht im Storage gefunden.', step: 'iso_invalid');
}
try {
$this->proxmox->mountIso((int) $vm->vmid, $isoVolid);
$vm->update(['attached_iso' => $isoVolid]);
$this->log($vm, $user, 'iso.mount', 'success', null, ['iso' => $isoVolid]);
} catch (\Throwable $e) {
$this->log($vm, $user, 'iso.mount', 'failed', $e->getMessage());
throw $e;
}
}
public function unmountIso(Customer $vm, User $user): void
{
$this->assertManageable($vm);
try {
$this->proxmox->unmountIso((int) $vm->vmid);
$vm->update(['attached_iso' => null]);
$this->log($vm, $user, 'iso.unmount', 'success');
} catch (\Throwable $e) {
$this->log($vm, $user, 'iso.unmount', 'failed', $e->getMessage());
throw $e;
}
}
/**
* @return array{token: string, ws_url: string, vmid: int, node: string, expires_at: int}
*/
public function createConsoleSession(Customer $vm, User $user): array
{
$this->assertManageable($vm);
$live = $this->refreshLiveStatus($vm);
if (($live['status'] ?? '') !== 'running') {
throw new ProxmoxException('Konsole nur bei laufender VM verfügbar. Bitte VM starten.', step: 'console_not_running');
}
$proxy = $this->proxmox->createVncProxy((int) $vm->vmid);
if ($proxy['port'] === '' || $proxy['ticket'] === '') {
throw new ProxmoxException('VNC-Proxy konnte nicht erstellt werden.', step: 'console_proxy');
}
$token = Str::random(64);
$proxmoxWsUrl = $this->proxmox->buildVncWebSocketUrl((int) $vm->vmid, $proxy['port'], $proxy['ticket']);
$expiresAt = now()->addMinutes(5)->timestamp;
$wsUrl = $proxmoxWsUrl;
if (config('hosting.console.proxy_enabled') && config('hosting.console.proxy_ws_url')) {
$wsUrl = rtrim(config('hosting.console.proxy_ws_url'), '/').'/'.$token;
}
Cache::put($this->consoleCacheKey($token), [
'customer_id' => $vm->id,
'user_id' => $user->id,
'ws_url' => $proxmoxWsUrl,
'ticket' => $proxy['ticket'],
'port' => $proxy['port'],
'vmid' => $vm->vmid,
'node' => $proxy['node'],
'cert' => $proxy['cert'],
], 300);
$this->log($vm, $user, 'console.open', 'success');
return [
'token' => $token,
'ws_url' => $wsUrl,
'vmid' => (int) $vm->vmid,
'node' => $proxy['node'],
'expires_at' => $expiresAt,
];
}
public function getConsoleSession(string $token, User $user): array
{
$data = Cache::get($this->consoleCacheKey($token));
if (! $data) {
throw new ProxmoxException('Konsole-Session abgelaufen oder ungültig.', step: 'console_expired');
}
if ($data['user_id'] !== $user->id && ! $user->isAdmin()) {
throw new ProxmoxException('Kein Zugriff auf diese Konsole.', step: 'console_forbidden');
}
return $data;
}
public function consoleCacheKey(string $token): string
{
return 'vm_console:'.$token;
}
private function log(Customer $vm, User $user, string $action, string $status, ?string $message = null, array $meta = []): void
{
VmActivityLog::query()->create([
'customer_id' => $vm->id,
'user_id' => $user->id,
'action' => $action,
'status' => $status,
'message' => $message,
'meta' => $meta ?: null,
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Hosting\Reinstall;
use App\Jobs\ProvisionCustomerJob;
use App\Models\Customer;
use App\Models\User;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use App\Services\Hosting\Snapshots\SnapshotService;
class ReinstallService
{
public function __construct(
private readonly ProxmoxClient $proxmox,
private readonly SnapshotService $snapshots,
) {}
public function reinstall(Customer $vm, User $user): void
{
if (! $vm->vmid) {
throw new \RuntimeException('VM ist nicht provisioniert.');
}
$this->snapshots->autoBeforeDestructive($vm, $user, 'reinstall');
$vmid = (int) $vm->vmid;
$this->proxmox->stopVM($vmid);
$this->proxmox->deleteVM($vmid);
$vm->update([
'status' => 'pending',
'provisioning_step' => 'queued',
'proxmox_status' => null,
'proxmox_uptime' => null,
'error_message' => null,
]);
ProvisionCustomerJob::dispatch($vm->id);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Services\Hosting\Snapshots;
use App\Models\Customer;
use App\Models\User;
use App\Models\VmSnapshot;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use Illuminate\Support\Str;
class SnapshotService
{
public function __construct(private readonly ProxmoxClient $proxmox) {}
public function create(Customer $vm, User $user, bool $auto = false, ?string $label = null): VmSnapshot
{
$name = 'snap-'.now()->format('Ymd-His').'-'.Str::lower(Str::random(4));
$this->proxmox->createSnapshot((int) $vm->vmid, $name);
$hours = (int) (config('hosting.snapshots.retention_hours') ?? 48);
return VmSnapshot::query()->create([
'customer_id' => $vm->id,
'name' => $label ?? $name,
'proxmox_snapshot_id' => $name,
'auto_created' => $auto,
'expires_at' => now()->addHours($hours),
]);
}
public function autoBeforeDestructive(Customer $vm, User $user, string $reason): void
{
if (! config('hosting.snapshots.auto_before_destructive', true)) {
return;
}
$this->create($vm, $user, true, "auto-{$reason}");
}
public function rollback(Customer $vm, VmSnapshot $snapshot): void
{
$this->proxmox->rollbackSnapshot((int) $vm->vmid, $snapshot->proxmox_snapshot_id);
}
public function delete(Customer $vm, VmSnapshot $snapshot): void
{
$this->proxmox->deleteSnapshot((int) $vm->vmid, $snapshot->proxmox_snapshot_id);
$snapshot->delete();
}
public function pruneExpired(): int
{
$count = 0;
$expired = VmSnapshot::query()->where('expires_at', '<=', now())->with('vm')->get();
foreach ($expired as $snapshot) {
if ($snapshot->vm?->vmid) {
try {
$this->proxmox->deleteSnapshot((int) $snapshot->vm->vmid, $snapshot->proxmox_snapshot_id);
} catch (\Throwable) {
// snapshot may already be gone in Proxmox
}
}
$snapshot->delete();
$count++;
}
return $count;
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace App\Services\Hosting\Traefik;
use App\Exceptions\Hosting\TraefikException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
use Symfony\Component\Yaml\Exception\ParseException;
class TraefikGenerator
{
private const MANAGED_PREFIX = 'customer-';
public function configPath(): string
{
return config('hosting.traefik.dynamic_config_path');
}
public function addCustomerRoute(string $domain, string $ip): void
{
$domain = $this->sanitizeDomain($domain);
$ip = $this->sanitizeIp($ip);
$config = $this->loadConfig();
$routerKey = $this->routerKey($domain);
$serviceKey = $this->serviceKey($domain);
$port = (int) config('hosting.traefik.backend_port', 80);
$entrypoint = config('hosting.traefik.entrypoint', 'websecure');
$certResolver = config('hosting.traefik.cert_resolver', 'letsencrypt');
$config['http'] ??= [];
$config['http']['routers'] ??= [];
$config['http']['services'] ??= [];
$config['http']['routers'][$routerKey] = [
'rule' => 'Host(`'.$this->escapeHostRule($domain).'`)',
'entryPoints' => [$entrypoint],
'service' => $serviceKey,
'tls' => [
'certResolver' => $certResolver,
],
];
$config['http']['services'][$serviceKey] = [
'loadBalancer' => [
'servers' => [
['url' => "http://{$ip}:{$port}"],
],
],
];
$this->writeConfig($config);
Log::info('Traefik: route added', ['domain' => $domain, 'ip' => $ip]);
}
public function removeCustomerRoute(string $domain): void
{
$domain = $this->sanitizeDomain($domain);
$config = $this->loadConfig();
$routerKey = $this->routerKey($domain);
$serviceKey = $this->serviceKey($domain);
unset($config['http']['routers'][$routerKey], $config['http']['services'][$serviceKey]);
$this->writeConfig($config);
Log::info('Traefik: route removed', ['domain' => $domain]);
}
public function rebuildAllRoutes(iterable $customers): void
{
$config = $this->loadConfig();
$config['http'] ??= [];
$config['http']['routers'] = $this->preserveNonManaged($config['http']['routers'] ?? [], 'routers');
$config['http']['services'] = $this->preserveNonManaged($config['http']['services'] ?? [], 'services');
foreach ($customers as $customer) {
if (empty($customer->domain) || empty($customer->ip_address)) {
continue;
}
$domain = $this->sanitizeDomain($customer->domain);
$ip = $this->sanitizeIp($customer->ip_address);
$routerKey = $this->routerKey($domain);
$serviceKey = $this->serviceKey($domain);
$port = (int) config('hosting.traefik.backend_port', 80);
$entrypoint = config('hosting.traefik.entrypoint', 'websecure');
$certResolver = config('hosting.traefik.cert_resolver', 'letsencrypt');
$config['http']['routers'][$routerKey] = [
'rule' => 'Host(`'.$this->escapeHostRule($domain).'`)',
'entryPoints' => [$entrypoint],
'service' => $serviceKey,
'tls' => ['certResolver' => $certResolver],
];
$config['http']['services'][$serviceKey] = [
'loadBalancer' => [
'servers' => [['url' => "http://{$ip}:{$port}"]],
],
];
}
$this->writeConfig($config);
Log::info('Traefik: all customer routes rebuilt');
}
public function reload(): void
{
$command = config('hosting.traefik.reload_command');
if (empty($command)) {
Log::info('Traefik: no reload command configured, skipping reload');
return;
}
$result = Process::timeout(30)->run($command);
if (! $result->successful()) {
throw new TraefikException(
'Traefik reload failed: '.$result->errorOutput(),
step: 'traefik_reload',
);
}
Log::info('Traefik: reload triggered');
}
private function loadConfig(): array
{
$path = $this->configPath();
if (! File::exists($path)) {
File::ensureDirectoryExists(dirname($path));
return ['http' => ['routers' => [], 'services' => []]];
}
$contents = File::get($path);
if (trim($contents) === '') {
return ['http' => ['routers' => [], 'services' => []]];
}
try {
$parsed = Yaml::parse($contents, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE);
return is_array($parsed) ? $parsed : ['http' => ['routers' => [], 'services' => []]];
} catch (ParseException $e) {
throw new TraefikException(
'Failed to parse Traefik config: '.$e->getMessage(),
step: 'traefik_parse',
previous: $e,
);
}
}
private function writeConfig(array $config): void
{
$path = $this->configPath();
File::ensureDirectoryExists(dirname($path));
$yaml = Yaml::dump($config, 6, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
if (str_contains($yaml, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $yaml)) {
throw new TraefikException('Refusing to write invalid Traefik YAML.', step: 'traefik_write');
}
$tempPath = $path.'.'.uniqid('tmp', true);
if (File::put($tempPath, $yaml) === false) {
throw new TraefikException('Failed to write temporary Traefik config.', step: 'traefik_write');
}
if (! @rename($tempPath, $path)) {
File::delete($tempPath);
throw new TraefikException('Atomic rename of Traefik config failed.', step: 'traefik_write');
}
}
private function preserveNonManaged(array $items, string $type): array
{
return array_filter(
$items,
fn ($key) => ! str_starts_with((string) $key, self::MANAGED_PREFIX),
ARRAY_FILTER_USE_KEY,
);
}
private function routerKey(string $domain): string
{
return self::MANAGED_PREFIX.$this->slug($domain).'-router';
}
private function serviceKey(string $domain): string
{
return self::MANAGED_PREFIX.$this->slug($domain).'-service';
}
private function slug(string $domain): string
{
return Str::slug(str_replace('.', '-', $domain), '-');
}
private function sanitizeDomain(string $domain): string
{
$domain = strtolower(trim($domain));
if (! preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $domain)) {
throw new TraefikException("Invalid domain: {$domain}", step: 'traefik_validation');
}
return $domain;
}
private function sanitizeIp(string $ip): string
{
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
throw new TraefikException("Invalid IP: {$ip}", step: 'traefik_validation');
}
return $ip;
}
private function escapeHostRule(string $domain): string
{
return str_replace(['`', '\\'], '', $domain);
}
}