initial commit
This commit is contained in:
21
app/Console/Commands/CollectVmMetricsCommand.php
Normal file
21
app/Console/Commands/CollectVmMetricsCommand.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Hosting\Metrics\MetricsCollectorService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CollectVmMetricsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:collect-metrics';
|
||||
|
||||
protected $description = 'Sammelt VM-Metriken von Proxmox';
|
||||
|
||||
public function handle(MetricsCollectorService $collector): int
|
||||
{
|
||||
$count = $collector->collectAll();
|
||||
$this->info("Metriken für {$count} VM(s) erfasst.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
21
app/Console/Commands/PruneSnapshotsCommand.php
Normal file
21
app/Console/Commands/PruneSnapshotsCommand.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneSnapshotsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:prune-snapshots';
|
||||
|
||||
protected $description = 'Entfernt abgelaufene VM-Snapshots';
|
||||
|
||||
public function handle(SnapshotService $snapshots): int
|
||||
{
|
||||
$count = $snapshots->pruneExpired();
|
||||
$this->info("{$count} Snapshot(s) bereinigt.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
35
app/Console/Commands/PurgeExpiredIsoUploadsCommand.php
Normal file
35
app/Console/Commands/PurgeExpiredIsoUploadsCommand.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CustomerIsoUpload;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PurgeExpiredIsoUploadsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:purge-iso-uploads';
|
||||
|
||||
protected $description = 'Remove expired customer ISO uploads from Proxmox storage';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$expired = CustomerIsoUpload::query()
|
||||
->where('expires_at', '<=', now())
|
||||
->get();
|
||||
|
||||
foreach ($expired as $upload) {
|
||||
try {
|
||||
// Proxmox delete via storage API when upload service is wired
|
||||
Log::info('ISO upload expired', ['volid' => $upload->volid, 'user_id' => $upload->user_id]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ISO purge failed', ['id' => $upload->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
$upload->delete();
|
||||
}
|
||||
|
||||
$this->info("Purged {$expired->count()} expired ISO upload(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
32
app/Console/Commands/RebuildTraefikRoutesCommand.php
Normal file
32
app/Console/Commands/RebuildTraefikRoutesCommand.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Traefik\TraefikGenerator;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RebuildTraefikRoutesCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:traefik-rebuild {--reload : Reload Traefik after rebuild}';
|
||||
|
||||
protected $description = 'Rebuild all customer Traefik routes from database';
|
||||
|
||||
public function handle(TraefikGenerator $traefik): int
|
||||
{
|
||||
$customers = Customer::query()
|
||||
->where('status', 'active')
|
||||
->whereNotNull('ip_address')
|
||||
->get();
|
||||
|
||||
$traefik->rebuildAllRoutes($customers);
|
||||
|
||||
if ($this->option('reload')) {
|
||||
$traefik->reload();
|
||||
}
|
||||
|
||||
$this->info("Rebuilt routes for {$customers->count()} active customers.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
21
app/Console/Commands/ReleaseVmidReservationsCommand.php
Normal file
21
app/Console/Commands/ReleaseVmidReservationsCommand.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Hosting\Provisioning\VmidReservationService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReleaseVmidReservationsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:release-vmids';
|
||||
|
||||
protected $description = 'Release VMIDs after retention period (default 48h)';
|
||||
|
||||
public function handle(VmidReservationService $service): int
|
||||
{
|
||||
$count = $service->releaseDue();
|
||||
$this->info("Released {$count} VMID reservation(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
17
app/Enums/IpPoolType.php
Normal file
17
app/Enums/IpPoolType.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum IpPoolType: string
|
||||
{
|
||||
case Private = 'private';
|
||||
case Public = 'public';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Private => 'Privat (intern)',
|
||||
self::Public => 'Öffentlich',
|
||||
};
|
||||
}
|
||||
}
|
||||
17
app/Enums/UserRole.php
Normal file
17
app/Enums/UserRole.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum UserRole: string
|
||||
{
|
||||
case Admin = 'admin';
|
||||
case Customer = 'customer';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Admin => 'Administrator',
|
||||
self::Customer => 'Kunde',
|
||||
};
|
||||
}
|
||||
}
|
||||
33
app/Enums/VmPowerAction.php
Normal file
33
app/Enums/VmPowerAction.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum VmPowerAction: string
|
||||
{
|
||||
case Start = 'start';
|
||||
case Shutdown = 'shutdown';
|
||||
case Stop = 'stop';
|
||||
case Reboot = 'reboot';
|
||||
case Reset = 'reset';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Start => 'Starten',
|
||||
self::Shutdown => 'Herunterfahren (ACPI)',
|
||||
self::Stop => 'Stoppen (Force)',
|
||||
self::Reboot => 'Neustart',
|
||||
self::Reset => 'Reset (Hard)',
|
||||
};
|
||||
}
|
||||
|
||||
public function requiresRunning(): bool
|
||||
{
|
||||
return in_array($this, [self::Shutdown, self::Stop, self::Reboot, self::Reset], true);
|
||||
}
|
||||
|
||||
public function requiresStopped(): bool
|
||||
{
|
||||
return $this === self::Start;
|
||||
}
|
||||
}
|
||||
7
app/Exceptions/Hosting/PleskException.php
Normal file
7
app/Exceptions/Hosting/PleskException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Hosting;
|
||||
|
||||
class PleskException extends ProvisioningException
|
||||
{
|
||||
}
|
||||
18
app/Exceptions/Hosting/ProvisioningException.php
Normal file
18
app/Exceptions/Hosting/ProvisioningException.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Hosting;
|
||||
|
||||
use Exception;
|
||||
|
||||
class ProvisioningException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?string $step = null,
|
||||
public readonly ?array $context = null,
|
||||
int $code = 0,
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
7
app/Exceptions/Hosting/ProxmoxException.php
Normal file
7
app/Exceptions/Hosting/ProxmoxException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Hosting;
|
||||
|
||||
class ProxmoxException extends ProvisioningException
|
||||
{
|
||||
}
|
||||
7
app/Exceptions/Hosting/TraefikException.php
Normal file
7
app/Exceptions/Hosting/TraefikException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Hosting;
|
||||
|
||||
class TraefikException extends ProvisioningException
|
||||
{
|
||||
}
|
||||
30
app/Http/Controllers/Api/ConsoleValidateController.php
Normal file
30
app/Http/Controllers/Api/ConsoleValidateController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ConsoleValidateController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $token, VmManagementService $management): JsonResponse
|
||||
{
|
||||
$secret = config('hosting.console.proxy_secret');
|
||||
if (! $secret || $request->header('X-Console-Proxy-Secret') !== $secret) {
|
||||
return response()->json(['error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = \Illuminate\Support\Facades\Cache::get($management->consoleCacheKey($token));
|
||||
if (! $data) {
|
||||
return response()->json(['error' => 'expired'], 410);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'proxmox_ws_url' => $data['ws_url'],
|
||||
'vmid' => $data['vmid'],
|
||||
'node' => $data['node'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
162
app/Http/Controllers/Api/WhmcsServiceController.php
Normal file
162
app/Http/Controllers/Api/WhmcsServiceController.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Whmcs\ProvisionServiceRequest;
|
||||
use App\Jobs\ProvisionCustomerJob;
|
||||
use App\Models\Customer;
|
||||
use App\Models\HostingPlan;
|
||||
use App\Models\User;
|
||||
use App\Models\WhmcsService;
|
||||
use App\Services\Hosting\Provisioning\DeprovisionService;
|
||||
use App\Services\Hosting\Provisioning\VmidReservationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WhmcsServiceController extends Controller
|
||||
{
|
||||
public function provision(ProvisionServiceRequest $request): JsonResponse
|
||||
{
|
||||
$plan = HostingPlan::query()->where('slug', $request->validated('plan_slug'))->firstOrFail();
|
||||
$domain = $request->domain();
|
||||
|
||||
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
|
||||
return response()->json(['error' => 'Domain already taken.'], 422);
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($request, $plan, $domain) {
|
||||
$user = User::query()->updateOrCreate(
|
||||
['whmcs_client_id' => $request->integer('whmcs_client_id')],
|
||||
[
|
||||
'name' => $request->validated('client_name'),
|
||||
'email' => $request->validated('client_email'),
|
||||
'password' => Hash::make(Str::random(32)),
|
||||
'role' => UserRole::Customer,
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$customer = Customer::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'hosting_plan_id' => $plan->id,
|
||||
'name' => $request->validated('hostname'),
|
||||
'domain' => $domain ?? 'direct-'.Str::slug($request->validated('hostname')).'-'.Str::lower(Str::random(6)).'.internal',
|
||||
'behind_traefik' => $request->boolean('behind_traefik', true),
|
||||
'provision_mode' => $request->validated('provision_mode'),
|
||||
'attached_iso' => $request->validated('iso_volid'),
|
||||
'cpu' => $plan->cpu,
|
||||
'ram' => $plan->ram,
|
||||
'disk' => $plan->disk,
|
||||
'status' => 'pending',
|
||||
'provisioning_step' => 'queued',
|
||||
]);
|
||||
|
||||
$whmcsService = WhmcsService::query()->create([
|
||||
'whmcs_service_id' => $request->integer('whmcs_service_id'),
|
||||
'whmcs_client_id' => $request->integer('whmcs_client_id'),
|
||||
'whmcs_order_id' => $request->input('whmcs_order_id'),
|
||||
'customer_id' => $customer->id,
|
||||
'user_id' => $user->id,
|
||||
'hosting_plan_id' => $plan->id,
|
||||
'status' => 'provisioning',
|
||||
'config' => $request->only([
|
||||
'provision_mode', 'template_slug', 'iso_volid', 'behind_traefik', 'subdomain',
|
||||
]),
|
||||
]);
|
||||
|
||||
$customer->update(['whmcs_service_id' => $whmcsService->id]);
|
||||
|
||||
$vmid = app(VmidReservationService::class)->reserveForCustomer($customer);
|
||||
$customer->update(['vmid' => $vmid]);
|
||||
|
||||
ProvisionCustomerJob::dispatch($customer->id);
|
||||
|
||||
return compact('customer', 'user', 'whmcsService', 'vmid');
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Provisioning queued.',
|
||||
'customer_id' => $result['customer']->id,
|
||||
'vmid' => $result['vmid'],
|
||||
'panel_url' => config('hosting.panel.url'),
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function suspend(Request $request, int $whmcsServiceId): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$customer = $service->customer;
|
||||
|
||||
if ($customer?->vmid) {
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->stopVM((int) $customer->vmid);
|
||||
}
|
||||
|
||||
$service->update(['status' => 'suspended']);
|
||||
$customer?->update(['status' => 'failed', 'provisioning_step' => 'suspended']);
|
||||
|
||||
return response()->json(['message' => 'Service suspended.']);
|
||||
}
|
||||
|
||||
public function unsuspend(Request $request, int $whmcsServiceId): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$customer = $service->customer;
|
||||
|
||||
if ($customer?->vmid) {
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->startVM((int) $customer->vmid);
|
||||
$customer->update(['status' => 'active', 'provisioning_step' => 'completed']);
|
||||
}
|
||||
|
||||
$service->update(['status' => 'active']);
|
||||
|
||||
return response()->json(['message' => 'Service unsuspended.']);
|
||||
}
|
||||
|
||||
public function terminate(Request $request, int $whmcsServiceId, DeprovisionService $deprovision): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$customer = $service->customer;
|
||||
|
||||
if ($customer) {
|
||||
$deprovision->terminatePermanently($customer);
|
||||
}
|
||||
|
||||
$service->update(['status' => 'terminated', 'customer_id' => null]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service terminated. VMID will be released after configured retention.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function status(int $whmcsServiceId): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$service->load('customer');
|
||||
|
||||
return response()->json([
|
||||
'whmcs_service_id' => $service->whmcs_service_id,
|
||||
'status' => $service->status,
|
||||
'customer' => $service->customer ? [
|
||||
'id' => $service->customer->id,
|
||||
'name' => $service->customer->name,
|
||||
'vmid' => $service->customer->vmid,
|
||||
'status' => $service->customer->status,
|
||||
'provisioning_step' => $service->customer->provisioning_step,
|
||||
'ip_address' => $service->customer->ip_address,
|
||||
'domain' => $service->customer->domain,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveService(int $whmcsServiceId): WhmcsService
|
||||
{
|
||||
return WhmcsService::query()
|
||||
->where('whmcs_service_id', $whmcsServiceId)
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Auth/LoginController.php
Normal file
50
app/Http/Controllers/Auth/LoginController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
return back()->withErrors(['email' => 'Ungültige Anmeldedaten.'])->onlyInput('email');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user->is_active) {
|
||||
Auth::logout();
|
||||
|
||||
return back()->withErrors(['email' => 'Ihr Konto ist deaktiviert.']);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('two_factor_passed');
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Auth/TwoFactorController.php
Normal file
65
app/Http/Controllers/Auth/TwoFactorController.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Auth\TwoFactorService;
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TwoFactorController extends Controller
|
||||
{
|
||||
public function setup(Request $request, TwoFactorService $twoFactor): View
|
||||
{
|
||||
$secret = $request->session()->get('two_factor_setup_secret');
|
||||
if (! $secret) {
|
||||
$secret = $twoFactor->generateSecret();
|
||||
$request->session()->put('two_factor_setup_secret', $secret);
|
||||
}
|
||||
|
||||
$qrUrl = $twoFactor->qrUrl($request->user(), $secret);
|
||||
$writer = new Writer(new ImageRenderer(new RendererStyle(200), new SvgImageBackEnd));
|
||||
$qrSvg = $writer->writeString($qrUrl);
|
||||
|
||||
return view('auth.two-factor-setup', [
|
||||
'secret' => $secret,
|
||||
'qrSvg' => $qrSvg,
|
||||
]);
|
||||
}
|
||||
|
||||
public function enable(Request $request, TwoFactorService $twoFactor): RedirectResponse
|
||||
{
|
||||
$request->validate(['code' => ['required', 'string', 'size:6']]);
|
||||
$secret = $request->session()->pull('two_factor_setup_secret');
|
||||
if (! $secret || ! $twoFactor->enable($request->user(), $secret, $request->input('code'))) {
|
||||
return back()->withErrors(['code' => 'Ungültiger Code. Bitte erneut versuchen.']);
|
||||
}
|
||||
|
||||
$request->session()->put('two_factor_passed', true);
|
||||
|
||||
return redirect()->route('dashboard')->with('success', 'Zwei-Faktor-Authentifizierung aktiviert.');
|
||||
}
|
||||
|
||||
public function challenge(): View
|
||||
{
|
||||
return view('auth.two-factor-challenge');
|
||||
}
|
||||
|
||||
public function verifyChallenge(Request $request, TwoFactorService $twoFactor): RedirectResponse
|
||||
{
|
||||
$request->validate(['code' => ['required', 'string']]);
|
||||
|
||||
if (! $twoFactor->verify($request->user(), $request->input('code'))) {
|
||||
return back()->withErrors(['code' => 'Ungültiger Authentifizierungscode.']);
|
||||
}
|
||||
|
||||
$request->session()->put('two_factor_passed', true);
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
}
|
||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
58
app/Http/Controllers/CustomerProvisioningController.php
Normal file
58
app/Http/Controllers/CustomerProvisioningController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreCustomerRequest;
|
||||
use App\Jobs\ProvisionCustomerJob;
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CustomerProvisioningController extends Controller
|
||||
{
|
||||
public function store(StoreCustomerRequest $request): JsonResponse
|
||||
{
|
||||
$domain = $request->domain();
|
||||
|
||||
$customer = Customer::query()->create([
|
||||
'name' => $request->validated('name'),
|
||||
'domain' => $domain,
|
||||
'cpu' => $request->integer('cpu', config('hosting.defaults.cpu')),
|
||||
'ram' => $request->integer('ram', config('hosting.defaults.ram')),
|
||||
'disk' => $request->integer('disk', config('hosting.defaults.disk')),
|
||||
'status' => 'pending',
|
||||
'provisioning_step' => 'queued',
|
||||
]);
|
||||
|
||||
ProvisionCustomerJob::dispatch($customer->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Provisioning started.',
|
||||
'customer' => $customer,
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function show(Customer $customer): JsonResponse
|
||||
{
|
||||
return response()->json(['customer' => $customer]);
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'customers' => Customer::query()->latest()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Customer $customer): JsonResponse
|
||||
{
|
||||
if ($customer->status === 'pending' && $customer->provisioning_step === 'queued') {
|
||||
$customer->delete();
|
||||
|
||||
return response()->json(['message' => 'Queued customer removed.']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Use deprovision endpoint for active customers.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
17
app/Http/Controllers/Web/Admin/SystemHealthController.php
Normal file
17
app/Http/Controllers/Web/Admin/SystemHealthController.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Hosting\Health\SystemHealthService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SystemHealthController extends Controller
|
||||
{
|
||||
public function __invoke(SystemHealthService $health): View
|
||||
{
|
||||
return view('admin.health', [
|
||||
'checks' => $health->checks(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/Web/Admin/VmTemplateController.php
Normal file
72
app/Http/Controllers/Web/Admin/VmTemplateController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VmTemplate;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VmTemplateController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$templates = VmTemplate::query()->orderBy('name')->get();
|
||||
|
||||
return view('admin.templates.index', compact('templates'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.templates.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug'],
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
|
||||
'os_family' => ['nullable', 'string', 'max:32'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
VmTemplate::query()->create([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.templates.index')->with('success', 'Template angelegt.');
|
||||
}
|
||||
|
||||
public function edit(VmTemplate $template): View
|
||||
{
|
||||
return view('admin.templates.edit', compact('template'));
|
||||
}
|
||||
|
||||
public function update(Request $request, VmTemplate $template): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug,'.$template->id],
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
|
||||
'os_family' => ['nullable', 'string', 'max:32'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$template->update([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.templates.index')->with('success', 'Template aktualisiert.');
|
||||
}
|
||||
|
||||
public function destroy(VmTemplate $template): RedirectResponse
|
||||
{
|
||||
$template->delete();
|
||||
|
||||
return redirect()->route('admin.templates.index')->with('success', 'Template gelöscht.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Web/DashboardController.php
Normal file
45
app/Http/Controllers/Web/DashboardController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$vmQuery = Customer::query()->forUser($user);
|
||||
|
||||
$stats = [
|
||||
'vms_total' => (clone $vmQuery)->count(),
|
||||
'vms_active' => (clone $vmQuery)->where('status', 'active')->count(),
|
||||
'vms_pending' => (clone $vmQuery)->where('status', 'pending')->count(),
|
||||
'vms_failed' => (clone $vmQuery)->where('status', 'failed')->count(),
|
||||
];
|
||||
|
||||
$pools = IpPool::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('type')
|
||||
->get();
|
||||
|
||||
$recentVms = Customer::query()
|
||||
->forUser($user)
|
||||
->with(['owner', 'ipPool'])
|
||||
->latest()
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return view('dashboard', [
|
||||
'stats' => $stats,
|
||||
'pools' => $pools,
|
||||
'recentVms' => $recentVms,
|
||||
'usersCount' => $user->isAdmin() ? User::query()->count() : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/Web/IpPoolController.php
Normal file
123
app/Http/Controllers/Web/IpPoolController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\IpPoolType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class IpPoolController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', IpPool::class);
|
||||
|
||||
$pools = IpPool::query()
|
||||
->withCount(['vms' => fn ($q) => $q->whereIn('status', ['pending', 'active'])])
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (IpPool $pool) {
|
||||
$pool->setAttribute('usage_percent', $pool->totalIps() > 0
|
||||
? round(($pool->usedIpsCount() / $pool->totalIps()) * 100, 1)
|
||||
: 0);
|
||||
|
||||
return $pool;
|
||||
});
|
||||
|
||||
$assignments = Customer::query()
|
||||
->forUser($request->user())
|
||||
->with(['owner', 'ipPool'])
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->where(fn ($q) => $q->whereNotNull('ip_address')->orWhereNotNull('public_ip'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('ip-pools.index', compact('pools', 'assignments'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', IpPool::class);
|
||||
|
||||
return view('ip-pools.create', ['types' => IpPoolType::cases()]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', IpPool::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'type' => ['required', Rule::enum(IpPoolType::class)],
|
||||
'start_ip' => ['required', 'ip'],
|
||||
'end_ip' => ['required', 'ip'],
|
||||
'gateway' => ['nullable', 'ip'],
|
||||
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
if (ip2long($data['start_ip']) > ip2long($data['end_ip'])) {
|
||||
return back()->withErrors(['end_ip' => 'End-IP muss größer als Start-IP sein.'])->withInput();
|
||||
}
|
||||
|
||||
IpPool::query()->create([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool erstellt.');
|
||||
}
|
||||
|
||||
public function edit(IpPool $ipPool): View
|
||||
{
|
||||
$this->authorize('update', $ipPool);
|
||||
|
||||
return view('ip-pools.edit', [
|
||||
'pool' => $ipPool,
|
||||
'types' => IpPoolType::cases(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, IpPool $ipPool): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $ipPool);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'type' => ['required', Rule::enum(IpPoolType::class)],
|
||||
'start_ip' => ['required', 'ip'],
|
||||
'end_ip' => ['required', 'ip'],
|
||||
'gateway' => ['nullable', 'ip'],
|
||||
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$ipPool->update([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool aktualisiert.');
|
||||
}
|
||||
|
||||
public function destroy(IpPool $ipPool): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $ipPool);
|
||||
|
||||
if ($ipPool->vms()->whereIn('status', ['pending', 'active'])->exists()) {
|
||||
return back()->withErrors(['pool' => 'Pool wird noch von aktiven VMs verwendet.']);
|
||||
}
|
||||
|
||||
$ipPool->delete();
|
||||
|
||||
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool gelöscht.');
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Web/IsoUploadController.php
Normal file
38
app/Http/Controllers/Web/IsoUploadController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerIsoUpload;
|
||||
use App\Services\Hosting\Iso\IsoUploadService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class IsoUploadController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$uploads = CustomerIsoUpload::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('iso-uploads.index', compact('uploads'));
|
||||
}
|
||||
|
||||
public function store(Request $request, IsoUploadService $service): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'iso' => ['required', 'file', 'mimes:iso', 'max:'.((int) config('hosting.iso_upload.max_size_mb', 10240) * 1024)],
|
||||
]);
|
||||
|
||||
try {
|
||||
$service->upload($request->user(), $request->file('iso'));
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return redirect()->route('iso-uploads.index')->with('success', 'ISO hochgeladen.');
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Web/UserController.php
Normal file
106
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$users = User::query()->withCount('vms')->orderBy('name')->paginate(20);
|
||||
|
||||
return view('users.index', compact('users'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
return view('users.create', ['roles' => UserRole::cases()]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'role' => ['required', Rule::enum(UserRole::class)],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
User::query()->create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
'role' => $data['role'],
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Benutzer erstellt.');
|
||||
}
|
||||
|
||||
public function edit(User $user): View
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
return view('users.edit', [
|
||||
'user' => $user,
|
||||
'roles' => UserRole::cases(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||
'role' => ['required', Rule::enum(UserRole::class)],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$user->fill([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'role' => $data['role'],
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$user->password = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Benutzer aktualisiert.');
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $user);
|
||||
|
||||
if ($user->vms()->whereIn('status', ['pending', 'active'])->exists()) {
|
||||
return back()->withErrors(['user' => 'Benutzer hat noch aktive VMs.']);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Benutzer gelöscht.');
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Web/VmBackupController.php
Normal file
24
app/Http/Controllers/Web/VmBackupController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Backups\BackupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VmBackupController extends Controller
|
||||
{
|
||||
public function store(Customer $vm, BackupService $backups): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
try {
|
||||
$backups->start($vm, auth()->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['backup' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Backup gestartet.');
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/VmConsoleController.php
Normal file
47
app/Http/Controllers/Web/VmConsoleController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VmConsoleController extends Controller
|
||||
{
|
||||
public function create(Request $request, Customer $vm, VmManagementService $management): View
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
try {
|
||||
$session = $management->createConsoleSession($vm, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return view('vms.console-error', [
|
||||
'vm' => $vm,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('vms.console', [
|
||||
'vm' => $vm,
|
||||
'token' => $session['token'],
|
||||
'wsUrl' => $session['ws_url'],
|
||||
'expiresAt' => $session['expires_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $token, VmManagementService $management): View
|
||||
{
|
||||
$session = $management->getConsoleSession($token, $request->user());
|
||||
$vm = Customer::query()->findOrFail($session['customer_id']);
|
||||
$this->authorize('view', $vm);
|
||||
|
||||
return view('vms.console', [
|
||||
'vm' => $vm,
|
||||
'token' => $token,
|
||||
'wsUrl' => $session['ws_url'],
|
||||
'expiresAt' => now()->addMinutes(5)->timestamp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
215
app/Http/Controllers/Web/VmController.php
Normal file
215
app/Http/Controllers/Web/VmController.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreVmRequest;
|
||||
use App\Http\Requests\UpdateVmRequest;
|
||||
use App\Jobs\ProvisionCustomerJob;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\User;
|
||||
use App\Models\VmDevice;
|
||||
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||
use App\Services\Hosting\Provisioning\DeprovisionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VmController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', Customer::class);
|
||||
|
||||
$vms = Customer::query()
|
||||
->forUser($request->user())
|
||||
->with(['owner', 'ipPool'])
|
||||
->when($request->query('status'), fn ($q, $status) => $q->where('status', $status))
|
||||
->latest()
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
return view('vms.index', compact('vms'));
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->authorize('create', Customer::class);
|
||||
|
||||
$isos = [];
|
||||
try {
|
||||
$isos = app(ProxmoxClient::class)->listIsos();
|
||||
} catch (\Throwable) {
|
||||
// Proxmox nicht erreichbar – Formular bleibt nutzbar
|
||||
}
|
||||
|
||||
return view('vms.create', [
|
||||
'privatePools' => IpPool::query()->where('type', 'private')->where('is_active', true)->get(),
|
||||
'customers' => $request->user()->isAdmin()
|
||||
? User::query()->where('role', 'customer')->orderBy('name')->get()
|
||||
: collect(),
|
||||
'deviceTypes' => VmDevice::typesFor($request->user()),
|
||||
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
'isos' => $isos,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreVmRequest $request): RedirectResponse
|
||||
{
|
||||
$domain = $request->domain();
|
||||
|
||||
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
|
||||
return back()->withErrors(['subdomain' => 'Diese Subdomain ist bereits vergeben.'])->withInput();
|
||||
}
|
||||
|
||||
if (! $domain) {
|
||||
$domain = 'direct-'.Str::slug($request->validated('name')).'-'.Str::lower(Str::random(6)).'.internal';
|
||||
}
|
||||
|
||||
$vm = Customer::query()->create([
|
||||
'user_id' => $request->ownerId(),
|
||||
'name' => $request->validated('name'),
|
||||
'domain' => $domain,
|
||||
'behind_traefik' => $request->boolean('behind_traefik'),
|
||||
'ip_pool_id' => $request->input('ip_pool_id'),
|
||||
'cpu' => $request->integer('cpu'),
|
||||
'ram' => $request->integer('ram'),
|
||||
'disk' => $request->integer('disk'),
|
||||
'attached_iso' => $request->validated('install_iso'),
|
||||
'status' => 'pending',
|
||||
'provisioning_step' => 'queued',
|
||||
]);
|
||||
|
||||
$this->syncDevices($vm, $request->input('devices', []));
|
||||
|
||||
ProvisionCustomerJob::dispatch($vm->id);
|
||||
|
||||
return redirect()
|
||||
->route('vms.show', $vm)
|
||||
->with('success', 'VM-Provisioning wurde gestartet.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Customer $vm): View
|
||||
{
|
||||
$this->authorize('view', $vm);
|
||||
|
||||
$vm->load([
|
||||
'owner', 'ipPool', 'devices',
|
||||
'snapshots' => fn ($q) => $q->latest(),
|
||||
'backups' => fn ($q) => $q->latest()->limit(10),
|
||||
'firewallRules',
|
||||
'metrics' => fn ($q) => $q->limit(48),
|
||||
'activityLogs' => fn ($q) => $q->limit(15)->with('user'),
|
||||
]);
|
||||
|
||||
$isos = [];
|
||||
$liveStatus = null;
|
||||
|
||||
if ($vm->vmid && $vm->status === 'active') {
|
||||
try {
|
||||
$proxmox = app(ProxmoxClient::class);
|
||||
$isos = $proxmox->listIsos();
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
|
||||
try {
|
||||
$proxmox ??= app(ProxmoxClient::class);
|
||||
$liveStatus = $proxmox->normalizeLiveStatus($proxmox->getVMStatus((int) $vm->vmid));
|
||||
$vm->update([
|
||||
'proxmox_status' => $liveStatus['status'],
|
||||
'proxmox_uptime' => $liveStatus['uptime'],
|
||||
'proxmox_status_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
return view('vms.show', compact('vm', 'isos', 'liveStatus'));
|
||||
}
|
||||
|
||||
public function edit(Request $request, Customer $vm): View
|
||||
{
|
||||
$this->authorize('update', $vm);
|
||||
|
||||
$vm->load('devices');
|
||||
|
||||
return view('vms.edit', [
|
||||
'vm' => $vm,
|
||||
'deviceTypes' => VmDevice::typesFor($request->user()),
|
||||
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateVmRequest $request, Customer $vm): RedirectResponse
|
||||
{
|
||||
$vm->update($request->only(['name', 'cpu', 'ram', 'disk']));
|
||||
|
||||
if ($request->has('devices')) {
|
||||
$vm->devices()->delete();
|
||||
$this->syncDevices($vm, $request->input('devices', []));
|
||||
}
|
||||
|
||||
if ($vm->vmid && $vm->status === 'active') {
|
||||
try {
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
|
||||
->updateVmResources((int) $vm->vmid, $vm->cpu, $vm->ram, $vm->disk);
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
|
||||
->applyDevices((int) $vm->vmid, $vm->devices()->get());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('warning', 'DB gespeichert, Proxmox-Update fehlgeschlagen: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('vms.show', $vm)->with('success', 'VM-Konfiguration gespeichert.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Customer $vm, DeprovisionService $deprovision, \App\Services\Hosting\Snapshots\SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $vm);
|
||||
|
||||
if ($vm->vmid && $vm->status === 'active') {
|
||||
try {
|
||||
$snapshots->autoBeforeDestructive($vm, $request->user(), 'delete');
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
if ($vm->status === 'pending' && $vm->provisioning_step === 'queued') {
|
||||
if ($vm->vmid) {
|
||||
app(\App\Services\Hosting\Provisioning\VmidReservationService::class)
|
||||
->scheduleRelease((int) $vm->vmid, $vm);
|
||||
}
|
||||
$vm->devices()->delete();
|
||||
$vm->delete();
|
||||
|
||||
return redirect()->route('vms.index')->with('success', 'VM-Eintrag gelöscht.');
|
||||
}
|
||||
|
||||
$deprovision->removeVmOnly($vm, $request->user());
|
||||
|
||||
return redirect()->route('vms.index')->with('success', 'VM wurde entfernt. VMID wird nach 48h freigegeben.');
|
||||
}
|
||||
|
||||
private function syncDevices(Customer $vm, array $devices): void
|
||||
{
|
||||
$allowed = array_keys(VmDevice::typesFor(auth()->user()));
|
||||
|
||||
foreach ($devices as $index => $device) {
|
||||
if (empty($device['type']) || ! in_array($device['type'], $allowed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vm->devices()->create([
|
||||
'type' => $device['type'],
|
||||
'slot' => $device['slot'] ?? null,
|
||||
'config' => $device['config'] ?? [],
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Web/VmFirewallController.php
Normal file
53
app/Http/Controllers/Web/VmFirewallController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Firewall\FirewallService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class VmFirewallController extends Controller
|
||||
{
|
||||
public function store(Request $request, Customer $vm, FirewallService $firewall): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
$data = $request->validate([
|
||||
'direction' => ['required', Rule::in(['in', 'out'])],
|
||||
'action' => ['required', Rule::in(['ACCEPT', 'DROP', 'REJECT'])],
|
||||
'protocol' => ['required', Rule::in(['tcp', 'udp', 'icmp'])],
|
||||
'port' => ['nullable', 'string', 'max:32'],
|
||||
'source' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$vm->firewallRules()->create([
|
||||
...$data,
|
||||
'sort_order' => $vm->firewallRules()->count(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$firewall->syncToProxmox($vm);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('warning', 'Regel gespeichert, Proxmox-Sync fehlgeschlagen: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Firewall-Regel hinzugefügt.');
|
||||
}
|
||||
|
||||
public function destroy(Customer $vm, int $rule, FirewallService $firewall): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
$vm->firewallRules()->whereKey($rule)->delete();
|
||||
|
||||
try {
|
||||
$firewall->syncToProxmox($vm);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
|
||||
return back()->with('success', 'Regel entfernt.');
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Web/VmIsoController.php
Normal file
55
app/Http/Controllers/Web/VmIsoController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmIsoController extends Controller
|
||||
{
|
||||
public function store(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
$data = $request->validate([
|
||||
'iso_volid' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$snapshots->autoBeforeDestructive($vm, $request->user(), 'iso-mount');
|
||||
|
||||
try {
|
||||
$management->mountIso($vm, $data['iso_volid'], $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'ISO wurde eingebunden. VM ggf. neu starten, um vom Installationsmedium zu booten.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Customer $vm, VmManagementService $management): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
try {
|
||||
$management->unmountIso($vm, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'ISO wurde entfernt.');
|
||||
}
|
||||
|
||||
public function index(ProxmoxClient $proxmox): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(['isos' => $proxmox->listIsos()]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/Http/Controllers/Web/VmPowerController.php
Normal file
36
app/Http/Controllers/Web/VmPowerController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\VmPowerAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class VmPowerController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
$action = VmPowerAction::from($request->validate([
|
||||
'action' => ['required', Rule::enum(VmPowerAction::class)],
|
||||
])['action']);
|
||||
|
||||
if (in_array($action, [VmPowerAction::Stop, VmPowerAction::Reset, VmPowerAction::Reboot], true)) {
|
||||
$snapshots->autoBeforeDestructive($vm, $request->user(), 'power-'.$action->value);
|
||||
}
|
||||
|
||||
try {
|
||||
$management->power($vm, $action, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['power' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', $action->label().' wurde ausgeführt.');
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Web/VmReinstallController.php
Normal file
26
app/Http/Controllers/Web/VmReinstallController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Reinstall\ReinstallService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmReinstallController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Customer $vm, ReinstallService $reinstall): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
$request->validate(['confirm' => ['required', 'in:REINSTALL']]);
|
||||
|
||||
try {
|
||||
$reinstall->reinstall($vm, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['reinstall' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return redirect()->route('vms.show', $vm)->with('success', 'Neuinstallation gestartet.');
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Web/VmSnapshotController.php
Normal file
55
app/Http/Controllers/Web/VmSnapshotController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\VmSnapshot;
|
||||
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmSnapshotController extends Controller
|
||||
{
|
||||
public function store(Request $request, Customer $vm, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
$request->validate(['label' => ['nullable', 'string', 'max:64']]);
|
||||
|
||||
try {
|
||||
$snapshots->create($vm, $request->user(), false, $request->input('label'));
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Snapshot erstellt.');
|
||||
}
|
||||
|
||||
public function rollback(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
abort_unless($snapshot->customer_id === $vm->id, 404);
|
||||
|
||||
try {
|
||||
$snapshots->rollback($vm, $snapshot);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Snapshot wiederhergestellt.');
|
||||
}
|
||||
|
||||
public function destroy(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
abort_unless($snapshot->customer_id === $vm->id, 404);
|
||||
|
||||
try {
|
||||
$snapshots->delete($vm, $snapshot);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Snapshot gelöscht.');
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/Web/VmStatusController.php
Normal file
42
app/Http/Controllers/Web/VmStatusController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmStatusController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Customer $vm, VmManagementService $management): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $vm);
|
||||
|
||||
if (! $vm->vmid || $vm->status !== 'active') {
|
||||
return response()->json([
|
||||
'status' => $vm->status,
|
||||
'proxmox' => null,
|
||||
'message' => 'VM nicht bereit',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$live = $management->refreshLiveStatus($vm);
|
||||
|
||||
return response()->json([
|
||||
'status' => $vm->status,
|
||||
'proxmox' => $live,
|
||||
'attached_iso' => $vm->attached_iso,
|
||||
'refreshed_at' => $vm->proxmox_status_at?->toIso8601String(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'status' => $vm->status,
|
||||
'proxmox' => null,
|
||||
'error' => $e->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Http/Middleware/EnsureTwoFactorVerified.php
Normal file
33
app/Http/Middleware/EnsureTwoFactorVerified.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Auth\TwoFactorService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTwoFactorVerified
|
||||
{
|
||||
public function __construct(private readonly TwoFactorService $twoFactor) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($this->twoFactor->mustSetup($user) && ! $request->routeIs('two-factor.*')) {
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
if ($this->twoFactor->isEnabled($user) && ! $request->session()->get('two_factor_passed')) {
|
||||
if (! $request->routeIs('two-factor.challenge', 'two-factor.challenge.store', 'logout')) {
|
||||
return redirect()->route('two-factor.challenge');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
19
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->isAdmin()) {
|
||||
abort(403, 'Zugriff nur für Administratoren.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
48
app/Http/Middleware/VerifyWhmcsRequest.php
Normal file
48
app/Http/Middleware/VerifyWhmcsRequest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class VerifyWhmcsRequest
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('hosting.whmcs.enabled', false)) {
|
||||
abort(503, 'WHMCS integration is disabled.');
|
||||
}
|
||||
|
||||
$secret = config('hosting.whmcs.api_secret');
|
||||
if (empty($secret)) {
|
||||
abort(500, 'WHMCS API secret is not configured.');
|
||||
}
|
||||
|
||||
$allowedIps = config('hosting.whmcs.allowed_ips', []);
|
||||
if ($allowedIps !== [] && ! in_array($request->ip(), $allowedIps, true)) {
|
||||
abort(403, 'IP not allowed.');
|
||||
}
|
||||
|
||||
$timestamp = (int) $request->header('X-Whmcs-Timestamp', 0);
|
||||
$signature = (string) $request->header('X-Whmcs-Signature', '');
|
||||
$window = (int) config('hosting.whmcs.replay_window_seconds', 300);
|
||||
|
||||
if ($timestamp === 0 || $signature === '') {
|
||||
abort(401, 'Missing WHMCS signature headers.');
|
||||
}
|
||||
|
||||
if (abs(time() - $timestamp) > $window) {
|
||||
abort(401, 'Request timestamp expired.');
|
||||
}
|
||||
|
||||
$payload = $timestamp.'.'.$request->getContent();
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
if (! hash_equals($expected, $signature)) {
|
||||
abort(401, 'Invalid WHMCS signature.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/StoreCustomerRequest.php
Normal file
42
app/Http/Requests/StoreCustomerRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$baseDomain = config('hosting.plesk.base_domain');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'subdomain' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:63',
|
||||
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||
function (string $attribute, mixed $value, \Closure $fail) use ($baseDomain): void {
|
||||
$domain = strtolower((string) $value).'.'.$baseDomain;
|
||||
if (\App\Models\Customer::query()->where('domain', $domain)->exists()) {
|
||||
$fail("The domain {$domain} is already registered.");
|
||||
}
|
||||
},
|
||||
],
|
||||
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
|
||||
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
|
||||
];
|
||||
}
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return strtolower($this->validated('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||
}
|
||||
}
|
||||
64
app/Http/Requests/StoreVmRequest.php
Normal file
64
app/Http/Requests/StoreVmRequest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreVmRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('create', Customer::class);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$baseDomain = config('hosting.plesk.base_domain');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'subdomain' => [
|
||||
Rule::requiredIf(fn () => $this->boolean('behind_traefik')),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:63',
|
||||
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||
],
|
||||
'behind_traefik' => ['boolean'],
|
||||
'user_id' => [
|
||||
Rule::requiredIf(fn () => $this->user()->isAdmin()),
|
||||
'nullable',
|
||||
'exists:users,id',
|
||||
],
|
||||
'ip_pool_id' => ['nullable', 'exists:ip_pools,id'],
|
||||
'cpu' => ['required', 'integer', 'min:1', 'max:32'],
|
||||
'ram' => ['required', 'integer', 'min:512', 'max:131072'],
|
||||
'disk' => ['required', 'integer', 'min:10', 'max:2048'],
|
||||
'devices' => ['nullable', 'array'],
|
||||
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
|
||||
'devices.*.slot' => ['nullable', 'string', 'max:32'],
|
||||
'devices.*.config' => ['nullable', 'array'],
|
||||
'install_iso' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
if (! $this->boolean('behind_traefik') || ! $this->filled('subdomain')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||
}
|
||||
|
||||
public function ownerId(): int
|
||||
{
|
||||
if ($this->user()->isAdmin() && $this->filled('user_id')) {
|
||||
return (int) $this->input('user_id');
|
||||
}
|
||||
|
||||
return $this->user()->id;
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/UpdateVmRequest.php
Normal file
28
app/Http/Requests/UpdateVmRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateVmRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update', $this->route('vm'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
|
||||
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
|
||||
'devices' => ['nullable', 'array'],
|
||||
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
|
||||
'devices.*.slot' => ['nullable', 'string', 'max:32'],
|
||||
'devices.*.config' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Http/Requests/Whmcs/ProvisionServiceRequest.php
Normal file
57
app/Http/Requests/Whmcs/ProvisionServiceRequest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Whmcs;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProvisionServiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'whmcs_service_id' => ['required', 'integer', 'unique:whmcs_services,whmcs_service_id'],
|
||||
'whmcs_client_id' => ['required', 'integer'],
|
||||
'whmcs_order_id' => ['nullable', 'integer'],
|
||||
'client_email' => ['required', 'email', 'max:255'],
|
||||
'client_name' => ['required', 'string', 'max:100'],
|
||||
'plan_slug' => ['required', 'string', 'exists:hosting_plans,slug'],
|
||||
'hostname' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'subdomain' => [
|
||||
Rule::requiredIf(fn () => $this->boolean('behind_traefik', true)),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:63',
|
||||
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||
],
|
||||
'behind_traefik' => ['boolean'],
|
||||
'provision_mode' => ['required', Rule::in(['template', 'iso', 'empty'])],
|
||||
'template_slug' => [
|
||||
Rule::requiredIf(fn () => $this->input('provision_mode') === 'template'),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:64',
|
||||
],
|
||||
'iso_volid' => [
|
||||
Rule::requiredIf(fn () => $this->input('provision_mode') === 'iso'),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
if (! $this->boolean('behind_traefik', true) || ! $this->filled('subdomain')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||
}
|
||||
}
|
||||
75
app/Jobs/ProvisionCustomerJob.php
Normal file
75
app/Jobs/ProvisionCustomerJob.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\DTO\CustomerProvisionData;
|
||||
use App\Services\Hosting\Notifications\HostingNotificationService;
|
||||
use App\Services\Hosting\Provisioning\ProvisioningService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class ProvisionCustomerJob implements ShouldQueue
|
||||
{
|
||||
use InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 30;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $customerId,
|
||||
) {}
|
||||
|
||||
public function handle(ProvisioningService $provisioning, HostingNotificationService $notifications): void
|
||||
{
|
||||
$customer = Customer::query()->with('owner')->findOrFail($this->customerId);
|
||||
|
||||
if ($customer->status === 'active') {
|
||||
Log::info('ProvisionCustomerJob: customer already active', ['id' => $customer->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$customer->update([
|
||||
'status' => 'pending',
|
||||
'provisioning_step' => 'queued',
|
||||
'error_message' => null,
|
||||
]);
|
||||
|
||||
$data = CustomerProvisionData::fromCustomer($customer);
|
||||
|
||||
$provisioning->provision($customer, $data);
|
||||
|
||||
if ($customer->owner) {
|
||||
$notifications->provisioningCompleted($customer->fresh(), $customer->owner);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::error('ProvisionCustomerJob failed permanently', [
|
||||
'customer_id' => $this->customerId,
|
||||
'error' => $exception?->getMessage(),
|
||||
]);
|
||||
|
||||
$customer = Customer::query()->with('owner')->find($this->customerId);
|
||||
if ($customer) {
|
||||
$customer->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => $exception?->getMessage() ?? 'Unknown provisioning failure',
|
||||
]);
|
||||
if ($customer->owner) {
|
||||
app(HostingNotificationService::class)->provisioningFailed(
|
||||
$customer,
|
||||
$customer->owner,
|
||||
$customer->error_message ?? 'Unbekannter Fehler',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/Models/Customer.php
Normal file
129
app/Models/Customer.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Customer extends Model
|
||||
{
|
||||
protected $table = 'customers';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'ip_pool_id',
|
||||
'name',
|
||||
'domain',
|
||||
'vmid',
|
||||
'ip_address',
|
||||
'public_ip',
|
||||
'behind_traefik',
|
||||
'provision_mode',
|
||||
'hosting_plan_id',
|
||||
'whmcs_service_id',
|
||||
'cpu',
|
||||
'ram',
|
||||
'disk',
|
||||
'attached_iso',
|
||||
'proxmox_status',
|
||||
'proxmox_uptime',
|
||||
'proxmox_status_at',
|
||||
'status',
|
||||
'provisioning_step',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'vmid' => 'integer',
|
||||
'cpu' => 'integer',
|
||||
'ram' => 'integer',
|
||||
'disk' => 'integer',
|
||||
'behind_traefik' => 'boolean',
|
||||
'proxmox_uptime' => 'integer',
|
||||
'proxmox_status_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function ipPool(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(IpPool::class, 'ip_pool_id');
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingPlan::class, 'hosting_plan_id');
|
||||
}
|
||||
|
||||
public function whmcsService(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WhmcsService::class, 'whmcs_service_id');
|
||||
}
|
||||
|
||||
public function vmidReservation(): \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
{
|
||||
return $this->hasOne(VmidReservation::class);
|
||||
}
|
||||
|
||||
public function devices(): HasMany
|
||||
{
|
||||
return $this->hasMany(VmDevice::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function snapshots(): HasMany
|
||||
{
|
||||
return $this->hasMany(VmSnapshot::class);
|
||||
}
|
||||
|
||||
public function backups(): HasMany
|
||||
{
|
||||
return $this->hasMany(VmBackup::class);
|
||||
}
|
||||
|
||||
public function firewallRules(): HasMany
|
||||
{
|
||||
return $this->hasMany(VmFirewallRule::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function metrics(): HasMany
|
||||
{
|
||||
return $this->hasMany(VmMetric::class)->orderByDesc('recorded_at');
|
||||
}
|
||||
|
||||
public function activityLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(VmActivityLog::class)->latest();
|
||||
}
|
||||
|
||||
public function isRunning(): bool
|
||||
{
|
||||
return $this->proxmox_status === 'running';
|
||||
}
|
||||
|
||||
public function scopeForUser(Builder $query, User $user): Builder
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
public function isProvisionable(): bool
|
||||
{
|
||||
return in_array($this->status, ['pending', 'failed'], true);
|
||||
}
|
||||
|
||||
public function displayName(): string
|
||||
{
|
||||
return $this->name.($this->vmid ? " (#{$this->vmid})" : '');
|
||||
}
|
||||
}
|
||||
30
app/Models/CustomerIsoUpload.php
Normal file
30
app/Models/CustomerIsoUpload.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CustomerIsoUpload extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'filename',
|
||||
'volid',
|
||||
'size_bytes',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size_bytes' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
41
app/Models/HostingPlan.php
Normal file
41
app/Models/HostingPlan.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class HostingPlan extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'cpu',
|
||||
'ram',
|
||||
'disk',
|
||||
'max_backups',
|
||||
'allow_public_ip',
|
||||
'allow_iso_upload',
|
||||
'is_active',
|
||||
'whmcs_product_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'cpu' => 'integer',
|
||||
'ram' => 'integer',
|
||||
'disk' => 'integer',
|
||||
'max_backups' => 'integer',
|
||||
'allow_public_ip' => 'boolean',
|
||||
'allow_iso_upload' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'whmcs_product_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function customers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Customer::class, 'hosting_plan_id');
|
||||
}
|
||||
}
|
||||
74
app/Models/IpPool.php
Normal file
74
app/Models/IpPool.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IpPoolType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class IpPool extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'start_ip',
|
||||
'end_ip',
|
||||
'gateway',
|
||||
'cidr',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => IpPoolType::class,
|
||||
'cidr' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function vms(): HasMany
|
||||
{
|
||||
return $this->hasMany(Customer::class, 'ip_pool_id');
|
||||
}
|
||||
|
||||
public function totalIps(): int
|
||||
{
|
||||
$start = ip2long($this->start_ip);
|
||||
$end = ip2long($this->end_ip);
|
||||
|
||||
if ($start === false || $end === false || $end < $start) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ($end - $start + 1);
|
||||
}
|
||||
|
||||
public function usedIpsCount(): int
|
||||
{
|
||||
$query = Customer::query()
|
||||
->where('ip_pool_id', $this->id)
|
||||
->whereIn('status', ['pending', 'active']);
|
||||
|
||||
if ($this->type === IpPoolType::Public) {
|
||||
return $query->whereNotNull('public_ip')->count();
|
||||
}
|
||||
|
||||
return $query->whereNotNull('ip_address')->count();
|
||||
}
|
||||
|
||||
public function freeIpsCount(): int
|
||||
{
|
||||
return max(0, $this->totalIps() - $this->usedIpsCount());
|
||||
}
|
||||
|
||||
public function containsIp(string $ip): bool
|
||||
{
|
||||
$long = ip2long($ip);
|
||||
$start = ip2long($this->start_ip);
|
||||
$end = ip2long($this->end_ip);
|
||||
|
||||
return $long !== false && $long >= $start && $long <= $end;
|
||||
}
|
||||
}
|
||||
32
app/Models/SystemSetting.php
Normal file
32
app/Models/SystemSetting.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SystemSetting extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Cache::remember("setting.{$key}", 300, function () use ($key, $default) {
|
||||
$row = static::query()->find($key);
|
||||
|
||||
return $row?->value ?? $default;
|
||||
});
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => (string) $value]);
|
||||
Cache::forget("setting.{$key}");
|
||||
}
|
||||
}
|
||||
46
app/Models/User.php
Normal file
46
app/Models/User.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'role', 'is_active', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at'])]
|
||||
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'role' => UserRole::class,
|
||||
'is_active' => 'boolean',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function vms(): HasMany
|
||||
{
|
||||
return $this->hasMany(Customer::class, 'user_id');
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === UserRole::Admin;
|
||||
}
|
||||
|
||||
public function isCustomer(): bool
|
||||
{
|
||||
return $this->role === UserRole::Customer;
|
||||
}
|
||||
}
|
||||
35
app/Models/VmActivityLog.php
Normal file
35
app/Models/VmActivityLog.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VmActivityLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'customer_id',
|
||||
'user_id',
|
||||
'action',
|
||||
'status',
|
||||
'message',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'meta' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function vm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
26
app/Models/VmBackup.php
Normal file
26
app/Models/VmBackup.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VmBackup extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'customer_id', 'user_id', 'storage', 'volume_id', 'status', 'size_bytes', 'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'size_bytes' => 'integer',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function vm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
}
|
||||
58
app/Models/VmDevice.php
Normal file
58
app/Models/VmDevice.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VmDevice extends Model
|
||||
{
|
||||
public const TYPE_DISK = 'disk';
|
||||
|
||||
public const TYPE_NETWORK = 'network';
|
||||
|
||||
public const TYPE_USB = 'usb';
|
||||
|
||||
public const TYPE_PCI = 'pci';
|
||||
|
||||
public static function types(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_DISK => 'Zusätzliche Festplatte',
|
||||
self::TYPE_NETWORK => 'Netzwerk-Interface',
|
||||
self::TYPE_USB => 'USB-Gerät',
|
||||
self::TYPE_PCI => 'PCI Passthrough',
|
||||
];
|
||||
}
|
||||
|
||||
public static function typesFor(?\App\Models\User $user = null): array
|
||||
{
|
||||
$types = self::types();
|
||||
if ($user && ! $user->isAdmin()) {
|
||||
return array_intersect_key($types, array_flip([self::TYPE_DISK, self::TYPE_NETWORK]));
|
||||
}
|
||||
|
||||
return $types;
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id',
|
||||
'type',
|
||||
'slot',
|
||||
'config',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'config' => 'array',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function vm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
}
|
||||
26
app/Models/VmFirewallRule.php
Normal file
26
app/Models/VmFirewallRule.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VmFirewallRule extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'customer_id', 'direction', 'action', 'protocol', 'port', 'source', 'is_active', 'sort_order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function vm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
}
|
||||
32
app/Models/VmMetric.php
Normal file
32
app/Models/VmMetric.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VmMetric extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id', 'cpu', 'mem', 'maxmem', 'disk', 'maxdisk', 'recorded_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'cpu' => 'float',
|
||||
'mem' => 'integer',
|
||||
'maxmem' => 'integer',
|
||||
'disk' => 'integer',
|
||||
'maxdisk' => 'integer',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function vm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
}
|
||||
26
app/Models/VmSnapshot.php
Normal file
26
app/Models/VmSnapshot.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VmSnapshot extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'customer_id', 'name', 'proxmox_snapshot_id', 'auto_created', 'expires_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'auto_created' => 'boolean',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function vm(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class, 'customer_id');
|
||||
}
|
||||
}
|
||||
20
app/Models/VmTemplate.php
Normal file
20
app/Models/VmTemplate.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class VmTemplate extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'slug', 'name', 'proxmox_template_vmid', 'os_family', 'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'proxmox_template_vmid' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Models/VmidReservation.php
Normal file
36
app/Models/VmidReservation.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class VmidReservation extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'vmid',
|
||||
'customer_id',
|
||||
'status',
|
||||
'release_at',
|
||||
'released_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'vmid' => 'integer',
|
||||
'release_at' => 'datetime',
|
||||
'released_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function isBlocking(int $vmid): bool
|
||||
{
|
||||
return in_array($this->status, ['reserved', 'active', 'pending_release'], true);
|
||||
}
|
||||
}
|
||||
45
app/Models/WhmcsService.php
Normal file
45
app/Models/WhmcsService.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WhmcsService extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'whmcs_service_id',
|
||||
'whmcs_client_id',
|
||||
'whmcs_order_id',
|
||||
'customer_id',
|
||||
'user_id',
|
||||
'hosting_plan_id',
|
||||
'status',
|
||||
'config',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'whmcs_service_id' => 'integer',
|
||||
'whmcs_client_id' => 'integer',
|
||||
'whmcs_order_id' => 'integer',
|
||||
'config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingPlan::class, 'hosting_plan_id');
|
||||
}
|
||||
}
|
||||
34
app/Policies/IpPoolPolicy.php
Normal file
34
app/Policies/IpPoolPolicy.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\IpPool;
|
||||
use App\Models\User;
|
||||
|
||||
class IpPoolPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->is_active;
|
||||
}
|
||||
|
||||
public function view(User $user, IpPool $ipPool): bool
|
||||
{
|
||||
return $user->is_active;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin() && $user->is_active;
|
||||
}
|
||||
|
||||
public function update(User $user, IpPool $ipPool): bool
|
||||
{
|
||||
return $user->isAdmin() && $user->is_active;
|
||||
}
|
||||
|
||||
public function delete(User $user, IpPool $ipPool): bool
|
||||
{
|
||||
return $user->isAdmin() && $user->is_active;
|
||||
}
|
||||
}
|
||||
28
app/Policies/UserPolicy.php
Normal file
28
app/Policies/UserPolicy.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class UserPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isAdmin() && $user->is_active;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin() && $user->is_active;
|
||||
}
|
||||
|
||||
public function update(User $user, User $model): bool
|
||||
{
|
||||
return $user->isAdmin() && $user->is_active;
|
||||
}
|
||||
|
||||
public function delete(User $user, User $model): bool
|
||||
{
|
||||
return $user->isAdmin() && $user->is_active && $user->id !== $model->id;
|
||||
}
|
||||
}
|
||||
39
app/Policies/VmPolicy.php
Normal file
39
app/Policies/VmPolicy.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
|
||||
class VmPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->is_active;
|
||||
}
|
||||
|
||||
public function view(User $user, Customer $vm): bool
|
||||
{
|
||||
return $user->is_active && ($user->isAdmin() || $vm->user_id === $user->id);
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->is_active;
|
||||
}
|
||||
|
||||
public function update(User $user, Customer $vm): bool
|
||||
{
|
||||
return $user->is_active && ($user->isAdmin() || $vm->user_id === $user->id);
|
||||
}
|
||||
|
||||
public function delete(User $user, Customer $vm): bool
|
||||
{
|
||||
return $user->is_active && ($user->isAdmin() || $vm->user_id === $user->id);
|
||||
}
|
||||
|
||||
public function manage(User $user, Customer $vm): bool
|
||||
{
|
||||
return $this->update($user, $vm) && $vm->status === 'active' && $vm->vmid !== null;
|
||||
}
|
||||
}
|
||||
36
app/Providers/AppServiceProvider.php
Normal file
36
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\User;
|
||||
use App\Policies\IpPoolPolicy;
|
||||
use App\Policies\UserPolicy;
|
||||
use App\Policies\VmPolicy;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$max = (int) config('hosting.security.login_max_attempts', 5);
|
||||
|
||||
return Limit::perMinute($max)->by($request->ip().'|'.$request->input('email'));
|
||||
});
|
||||
|
||||
Gate::policy(Customer::class, VmPolicy::class);
|
||||
Gate::policy(IpPool::class, IpPoolPolicy::class);
|
||||
Gate::policy(User::class, UserPolicy::class);
|
||||
}
|
||||
}
|
||||
36
app/Providers/HostingServiceProvider.php
Normal file
36
app/Providers/HostingServiceProvider.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Hosting\Plesk\PleskClient;
|
||||
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||
use App\Services\Hosting\Provisioning\IpAddressAllocator;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use App\Services\Hosting\Provisioning\ProvisioningService;
|
||||
use App\Services\Hosting\Traefik\TraefikGenerator;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class HostingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ProxmoxClient::class);
|
||||
$this->app->singleton(PleskClient::class);
|
||||
$this->app->singleton(TraefikGenerator::class);
|
||||
$this->app->singleton(IpAddressAllocator::class);
|
||||
$this->app->singleton(ProvisioningService::class);
|
||||
$this->app->singleton(VmManagementService::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('vm-power', function (Request $request) {
|
||||
$limit = (int) config('hosting.vm_power.rate_limit_per_minute', 20);
|
||||
|
||||
return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
87
app/Services/Auth/TwoFactorService.php
Normal file
87
app/Services/Auth/TwoFactorService.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
class TwoFactorService
|
||||
{
|
||||
public function __construct(private readonly Google2FA $google2fa) {}
|
||||
|
||||
public function generateSecret(): string
|
||||
{
|
||||
return $this->google2fa->generateSecretKey();
|
||||
}
|
||||
|
||||
public function qrUrl(User $user, string $secret): string
|
||||
{
|
||||
$company = config('app.name', 'HexaHost Panel');
|
||||
|
||||
return $this->google2fa->getQRCodeUrl($company, $user->email, $secret);
|
||||
}
|
||||
|
||||
public function verify(User $user, string $code): bool
|
||||
{
|
||||
$secret = $this->decryptSecret($user);
|
||||
if (! $secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->google2fa->verifyKey($secret, $code);
|
||||
}
|
||||
|
||||
public function enable(User $user, string $secret, string $code): bool
|
||||
{
|
||||
if (! $this->google2fa->verifyKey($secret, $code)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$recovery = collect(range(1, 8))->map(fn () => bin2hex(random_bytes(4)))->implode(',');
|
||||
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => Crypt::encryptString($secret),
|
||||
'two_factor_recovery_codes' => Crypt::encryptString($recovery),
|
||||
'two_factor_confirmed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function disable(User $user): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'two_factor_confirmed_at' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
public function isEnabled(User $user): bool
|
||||
{
|
||||
return $user->two_factor_confirmed_at !== null && $user->two_factor_secret !== null;
|
||||
}
|
||||
|
||||
public function mustSetup(User $user): bool
|
||||
{
|
||||
if (! config('hosting.security.admin_2fa_required', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin() && ! $this->isEnabled($user);
|
||||
}
|
||||
|
||||
public function decryptSecret(User $user): ?string
|
||||
{
|
||||
if (! $user->two_factor_secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($user->two_factor_secret);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
app/Services/Hosting/Backups/BackupService.php
Normal file
73
app/Services/Hosting/Backups/BackupService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/Services/Hosting/DTO/CustomerProvisionData.php
Normal file
74
app/Services/Hosting/DTO/CustomerProvisionData.php
Normal 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 !== '';
|
||||
}
|
||||
}
|
||||
33
app/Services/Hosting/Firewall/FirewallService.php
Normal file
33
app/Services/Hosting/Firewall/FirewallService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/Services/Hosting/Health/SystemHealthService.php
Normal file
71
app/Services/Hosting/Health/SystemHealthService.php
Normal 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?)'];
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Services/Hosting/Iso/IsoUploadService.php
Normal file
67
app/Services/Hosting/Iso/IsoUploadService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
46
app/Services/Hosting/Metrics/MetricsCollectorService.php
Normal file
46
app/Services/Hosting/Metrics/MetricsCollectorService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
219
app/Services/Hosting/Plesk/PleskClient.php
Normal file
219
app/Services/Hosting/Plesk/PleskClient.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
86
app/Services/Hosting/Provisioning/DeprovisionService.php
Normal file
86
app/Services/Hosting/Provisioning/DeprovisionService.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
162
app/Services/Hosting/Provisioning/IpAddressAllocator.php
Normal file
162
app/Services/Hosting/Provisioning/IpAddressAllocator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
69
app/Services/Hosting/Provisioning/ProvisioningRollback.php
Normal file
69
app/Services/Hosting/Provisioning/ProvisioningRollback.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
262
app/Services/Hosting/Provisioning/ProvisioningService.php
Normal file
262
app/Services/Hosting/Provisioning/ProvisioningService.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
104
app/Services/Hosting/Provisioning/VmidReservationService.php
Normal file
104
app/Services/Hosting/Provisioning/VmidReservationService.php
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
591
app/Services/Hosting/Proxmox/ProxmoxClient.php
Normal file
591
app/Services/Hosting/Proxmox/ProxmoxClient.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/Services/Hosting/Proxmox/VmManagementService.php
Normal file
192
app/Services/Hosting/Proxmox/VmManagementService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
app/Services/Hosting/Reinstall/ReinstallService.php
Normal file
40
app/Services/Hosting/Reinstall/ReinstallService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
70
app/Services/Hosting/Snapshots/SnapshotService.php
Normal file
70
app/Services/Hosting/Snapshots/SnapshotService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
233
app/Services/Hosting/Traefik/TraefikGenerator.php
Normal file
233
app/Services/Hosting/Traefik/TraefikGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user