-
+ @foreach($errors->all() as $error)
+
- {{ $error }} + @endforeach +
commit 75299b723d0f0e764e56e7db7072710a654dd8e4 Author: TheOnlyMace <0815cracky@gmail.com> Date: Sun May 17 13:26:14 2026 +0200 initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ee55f94 --- /dev/null +++ b/.env.example @@ -0,0 +1,138 @@ +APP_NAME="HexaHost Panel" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=https://panel.hexahost.de + +APP_LOCALE=de +APP_FALLBACK_LOCALE=de +APP_FAKER_LOCALE=de_DE + +APP_MAINTENANCE_DRIVER=file + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +# --- Datenbank (Produktion: mariadb/mysql auf Plesk) --- +DB_CONNECTION=sqlite +# DB_CONNECTION=mariadb +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=hexahost_panel +# DB_USERNAME=panel_user +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="panel@hexahost.de" +MAIL_FROM_NAME="${APP_NAME}" + +VITE_APP_NAME="${APP_NAME}" + +# --- Proxmox VE --- +PROXMOX_URL=https://hyperion.rz-wob.hexahost.de:8006 +PROXMOX_CONSOLE_WS_URL=https://hyperion.rz-wob.hexahost.de:8006 +PROXMOX_TOKEN=user@pam!tokenid=secret +PROXMOX_NODE=hyperion +PROXMOX_STORAGE=vmdata +PROXMOX_BRIDGE=vmbr0 +PROXMOX_PUBLIC_BRIDGE=vmbr1 +PROXMOX_ISO_STORAGE=ISO +PROXMOX_ISO_DEVICE=ide2 +PROXMOX_BACKUP_STORAGE=inett-PBS +PROXMOX_TEMPLATE_VMID= +PROXMOX_TIMEOUT=120 +PROXMOX_VERIFY_SSL=true + +# --- Privates VM-Netz 10.32.0.0/24 --- +HOSTING_GATEWAY=10.32.0.1 +HOSTING_NETWORK_CIDR=24 +HOSTING_IP_POOL_START=10.32.0.10 +HOSTING_IP_POOL_END=10.32.0.254 + +# --- Öffentliche IPs (ohne Traefik) --- +HOSTING_PUBLIC_POOL_START=185.45.149.246 +HOSTING_PUBLIC_POOL_END=185.45.149.252 +HOSTING_PUBLIC_GATEWAY=185.45.149.241 +HOSTING_PUBLIC_CIDR=28 + +# --- VMID-Pool --- +VMID_RANGE_START=2000 +VMID_RANGE_END=2999 +VMID_RELEASE_AFTER_HOURS=48 + +# --- Snapshots / Backups / ISO --- +SNAPSHOT_RETENTION_HOURS=48 +SNAPSHOT_AUTO_BEFORE_DESTRUCTIVE=true +MAX_BACKUPS_PER_CUSTOMER=4 +BACKUPS_ENABLED=false + +ISO_UPLOAD_ENABLED=true +ISO_UPLOAD_MAX_PER_CUSTOMER=1 +ISO_UPLOAD_MAX_SIZE_MB=10240 +ISO_UPLOAD_RETENTION_HOURS=48 + +# --- WHMCS API --- +WHMCS_ENABLED=true +WHMCS_API_SECRET= +WHMCS_ALLOWED_IPS= +WHMCS_REPLAY_WINDOW=300 + +# --- Plesk DNS --- +PLESK_URL=https://plesk.example.com:8443 +PLESK_USER=admin +PLESK_PASS= +PLESK_BASE_DOMAIN=hexahost.de +PLESK_TIMEOUT=30 +PLESK_VERIFY_SSL=true +PLESK_MAIL_ENABLED=true + +# --- Traefik (File Provider) --- +TRAEFIK_DYNAMIC_CONFIG_PATH=/etc/traefik/dynamic/customers.yaml +TRAEFIK_ENTRYPOINT=websecure +TRAEFIK_CERT_RESOLVER=letsencrypt +TRAEFIK_BACKEND_PORT=80 +TRAEFIK_PUBLIC_IP=185.45.149.98 +TRAEFIK_RELOAD_COMMAND="docker exec traefik kill -HUP 1" + +# --- Sicherheit / Konsole --- +ADMIN_2FA_REQUIRED=true +LOGIN_MAX_ATTEMPTS=5 +VM_POWER_RATE_LIMIT=20 + +CONSOLE_PROXY_ENABLED=false +CONSOLE_PROXY_WS_URL=wss://panel.hexahost.de/ws/vm +CONSOLE_PROXY_SECRET= +CONSOLE_PROXY_VALIDATE_URL=https://panel.hexahost.de/api/console/validate + +# --- Benachrichtigungen --- +HOSTING_WEBHOOK_URL= + +# --- Standard-Ressourcen neuer VMs --- +HOSTING_DEFAULT_CPU=2 +HOSTING_DEFAULT_RAM=2048 +HOSTING_DEFAULT_DISK=32 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7be55e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ad1377 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +
+ + + +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. + +## Agentic Development + +Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: + +```bash +composer require laravel/boost --dev + +php artisan boost:install +``` + +Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Console/Commands/CollectVmMetricsCommand.php b/app/Console/Commands/CollectVmMetricsCommand.php new file mode 100644 index 0000000..6ebc1f1 --- /dev/null +++ b/app/Console/Commands/CollectVmMetricsCommand.php @@ -0,0 +1,21 @@ +collectAll(); + $this->info("Metriken für {$count} VM(s) erfasst."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PruneSnapshotsCommand.php b/app/Console/Commands/PruneSnapshotsCommand.php new file mode 100644 index 0000000..7b175ea --- /dev/null +++ b/app/Console/Commands/PruneSnapshotsCommand.php @@ -0,0 +1,21 @@ +pruneExpired(); + $this->info("{$count} Snapshot(s) bereinigt."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PurgeExpiredIsoUploadsCommand.php b/app/Console/Commands/PurgeExpiredIsoUploadsCommand.php new file mode 100644 index 0000000..aa4a603 --- /dev/null +++ b/app/Console/Commands/PurgeExpiredIsoUploadsCommand.php @@ -0,0 +1,35 @@ +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; + } +} diff --git a/app/Console/Commands/RebuildTraefikRoutesCommand.php b/app/Console/Commands/RebuildTraefikRoutesCommand.php new file mode 100644 index 0000000..7a9c686 --- /dev/null +++ b/app/Console/Commands/RebuildTraefikRoutesCommand.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/app/Console/Commands/ReleaseVmidReservationsCommand.php b/app/Console/Commands/ReleaseVmidReservationsCommand.php new file mode 100644 index 0000000..3eea824 --- /dev/null +++ b/app/Console/Commands/ReleaseVmidReservationsCommand.php @@ -0,0 +1,21 @@ +releaseDue(); + $this->info("Released {$count} VMID reservation(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/IpPoolType.php b/app/Enums/IpPoolType.php new file mode 100644 index 0000000..5c8ca0b --- /dev/null +++ b/app/Enums/IpPoolType.php @@ -0,0 +1,17 @@ + 'Privat (intern)', + self::Public => 'Öffentlich', + }; + } +} diff --git a/app/Enums/UserRole.php b/app/Enums/UserRole.php new file mode 100644 index 0000000..ca2deb6 --- /dev/null +++ b/app/Enums/UserRole.php @@ -0,0 +1,17 @@ + 'Administrator', + self::Customer => 'Kunde', + }; + } +} diff --git a/app/Enums/VmPowerAction.php b/app/Enums/VmPowerAction.php new file mode 100644 index 0000000..6eefde0 --- /dev/null +++ b/app/Enums/VmPowerAction.php @@ -0,0 +1,33 @@ + '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; + } +} diff --git a/app/Exceptions/Hosting/PleskException.php b/app/Exceptions/Hosting/PleskException.php new file mode 100644 index 0000000..6f012ea --- /dev/null +++ b/app/Exceptions/Hosting/PleskException.php @@ -0,0 +1,7 @@ +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'], + ]); + } +} diff --git a/app/Http/Controllers/Api/WhmcsServiceController.php b/app/Http/Controllers/Api/WhmcsServiceController.php new file mode 100644 index 0000000..5b979b3 --- /dev/null +++ b/app/Http/Controllers/Api/WhmcsServiceController.php @@ -0,0 +1,162 @@ +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(); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..76ee49e --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,50 @@ +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'); + } +} diff --git a/app/Http/Controllers/Auth/TwoFactorController.php b/app/Http/Controllers/Auth/TwoFactorController.php new file mode 100644 index 0000000..d32723f --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactorController.php @@ -0,0 +1,65 @@ +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')); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +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); + } +} diff --git a/app/Http/Controllers/Web/Admin/SystemHealthController.php b/app/Http/Controllers/Web/Admin/SystemHealthController.php new file mode 100644 index 0000000..52457cf --- /dev/null +++ b/app/Http/Controllers/Web/Admin/SystemHealthController.php @@ -0,0 +1,17 @@ + $health->checks(), + ]); + } +} diff --git a/app/Http/Controllers/Web/Admin/VmTemplateController.php b/app/Http/Controllers/Web/Admin/VmTemplateController.php new file mode 100644 index 0000000..2ff02bd --- /dev/null +++ b/app/Http/Controllers/Web/Admin/VmTemplateController.php @@ -0,0 +1,72 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/DashboardController.php b/app/Http/Controllers/Web/DashboardController.php new file mode 100644 index 0000000..489a7e4 --- /dev/null +++ b/app/Http/Controllers/Web/DashboardController.php @@ -0,0 +1,45 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Web/IpPoolController.php b/app/Http/Controllers/Web/IpPoolController.php new file mode 100644 index 0000000..cd32f19 --- /dev/null +++ b/app/Http/Controllers/Web/IpPoolController.php @@ -0,0 +1,123 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/IsoUploadController.php b/app/Http/Controllers/Web/IsoUploadController.php new file mode 100644 index 0000000..c5508f4 --- /dev/null +++ b/app/Http/Controllers/Web/IsoUploadController.php @@ -0,0 +1,38 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/UserController.php b/app/Http/Controllers/Web/UserController.php new file mode 100644 index 0000000..11722ff --- /dev/null +++ b/app/Http/Controllers/Web/UserController.php @@ -0,0 +1,106 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/VmBackupController.php b/app/Http/Controllers/Web/VmBackupController.php new file mode 100644 index 0000000..cd2b3c9 --- /dev/null +++ b/app/Http/Controllers/Web/VmBackupController.php @@ -0,0 +1,24 @@ +authorize('manage', $vm); + + try { + $backups->start($vm, auth()->user()); + } catch (\Throwable $e) { + return back()->withErrors(['backup' => $e->getMessage()]); + } + + return back()->with('success', 'Backup gestartet.'); + } +} diff --git a/app/Http/Controllers/Web/VmConsoleController.php b/app/Http/Controllers/Web/VmConsoleController.php new file mode 100644 index 0000000..6863ba7 --- /dev/null +++ b/app/Http/Controllers/Web/VmConsoleController.php @@ -0,0 +1,47 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Web/VmController.php b/app/Http/Controllers/Web/VmController.php new file mode 100644 index 0000000..59bbccb --- /dev/null +++ b/app/Http/Controllers/Web/VmController.php @@ -0,0 +1,215 @@ +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, + ]); + } + } +} diff --git a/app/Http/Controllers/Web/VmFirewallController.php b/app/Http/Controllers/Web/VmFirewallController.php new file mode 100644 index 0000000..0cba5b3 --- /dev/null +++ b/app/Http/Controllers/Web/VmFirewallController.php @@ -0,0 +1,53 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/VmIsoController.php b/app/Http/Controllers/Web/VmIsoController.php new file mode 100644 index 0000000..10239bf --- /dev/null +++ b/app/Http/Controllers/Web/VmIsoController.php @@ -0,0 +1,55 @@ +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); + } + } +} diff --git a/app/Http/Controllers/Web/VmPowerController.php b/app/Http/Controllers/Web/VmPowerController.php new file mode 100644 index 0000000..42357ee --- /dev/null +++ b/app/Http/Controllers/Web/VmPowerController.php @@ -0,0 +1,36 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/VmReinstallController.php b/app/Http/Controllers/Web/VmReinstallController.php new file mode 100644 index 0000000..f8ad4e7 --- /dev/null +++ b/app/Http/Controllers/Web/VmReinstallController.php @@ -0,0 +1,26 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/VmSnapshotController.php b/app/Http/Controllers/Web/VmSnapshotController.php new file mode 100644 index 0000000..e337a9f --- /dev/null +++ b/app/Http/Controllers/Web/VmSnapshotController.php @@ -0,0 +1,55 @@ +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.'); + } +} diff --git a/app/Http/Controllers/Web/VmStatusController.php b/app/Http/Controllers/Web/VmStatusController.php new file mode 100644 index 0000000..ee90afa --- /dev/null +++ b/app/Http/Controllers/Web/VmStatusController.php @@ -0,0 +1,42 @@ +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); + } + } +} diff --git a/app/Http/Middleware/EnsureTwoFactorVerified.php b/app/Http/Middleware/EnsureTwoFactorVerified.php new file mode 100644 index 0000000..5219606 --- /dev/null +++ b/app/Http/Middleware/EnsureTwoFactorVerified.php @@ -0,0 +1,33 @@ +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); + } +} diff --git a/app/Http/Middleware/EnsureUserIsAdmin.php b/app/Http/Middleware/EnsureUserIsAdmin.php new file mode 100644 index 0000000..dd032c5 --- /dev/null +++ b/app/Http/Middleware/EnsureUserIsAdmin.php @@ -0,0 +1,19 @@ +user()?->isAdmin()) { + abort(403, 'Zugriff nur für Administratoren.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/VerifyWhmcsRequest.php b/app/Http/Middleware/VerifyWhmcsRequest.php new file mode 100644 index 0000000..8841072 --- /dev/null +++ b/app/Http/Middleware/VerifyWhmcsRequest.php @@ -0,0 +1,48 @@ +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); + } +} diff --git a/app/Http/Requests/StoreCustomerRequest.php b/app/Http/Requests/StoreCustomerRequest.php new file mode 100644 index 0000000..d82272d --- /dev/null +++ b/app/Http/Requests/StoreCustomerRequest.php @@ -0,0 +1,42 @@ + ['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'); + } +} diff --git a/app/Http/Requests/StoreVmRequest.php b/app/Http/Requests/StoreVmRequest.php new file mode 100644 index 0000000..bee0b15 --- /dev/null +++ b/app/Http/Requests/StoreVmRequest.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/app/Http/Requests/UpdateVmRequest.php b/app/Http/Requests/UpdateVmRequest.php new file mode 100644 index 0000000..4f9ac2a --- /dev/null +++ b/app/Http/Requests/UpdateVmRequest.php @@ -0,0 +1,28 @@ +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'], + ]; + } +} diff --git a/app/Http/Requests/Whmcs/ProvisionServiceRequest.php b/app/Http/Requests/Whmcs/ProvisionServiceRequest.php new file mode 100644 index 0000000..454c369 --- /dev/null +++ b/app/Http/Requests/Whmcs/ProvisionServiceRequest.php @@ -0,0 +1,57 @@ + ['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'); + } +} diff --git a/app/Jobs/ProvisionCustomerJob.php b/app/Jobs/ProvisionCustomerJob.php new file mode 100644 index 0000000..84a5a37 --- /dev/null +++ b/app/Jobs/ProvisionCustomerJob.php @@ -0,0 +1,75 @@ +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', + ); + } + } + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 0000000..16c3fc7 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,129 @@ + '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})" : ''); + } +} diff --git a/app/Models/CustomerIsoUpload.php b/app/Models/CustomerIsoUpload.php new file mode 100644 index 0000000..ca4cb94 --- /dev/null +++ b/app/Models/CustomerIsoUpload.php @@ -0,0 +1,30 @@ + 'integer', + 'expires_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/HostingPlan.php b/app/Models/HostingPlan.php new file mode 100644 index 0000000..d62027c --- /dev/null +++ b/app/Models/HostingPlan.php @@ -0,0 +1,41 @@ + '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'); + } +} diff --git a/app/Models/IpPool.php b/app/Models/IpPool.php new file mode 100644 index 0000000..2502629 --- /dev/null +++ b/app/Models/IpPool.php @@ -0,0 +1,74 @@ + 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; + } +} diff --git a/app/Models/SystemSetting.php b/app/Models/SystemSetting.php new file mode 100644 index 0000000..669280f --- /dev/null +++ b/app/Models/SystemSetting.php @@ -0,0 +1,32 @@ +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}"); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..03e159c --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,46 @@ + */ + 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; + } +} diff --git a/app/Models/VmActivityLog.php b/app/Models/VmActivityLog.php new file mode 100644 index 0000000..ba6c009 --- /dev/null +++ b/app/Models/VmActivityLog.php @@ -0,0 +1,35 @@ + 'array', + ]; + } + + public function vm(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/VmBackup.php b/app/Models/VmBackup.php new file mode 100644 index 0000000..28615a3 --- /dev/null +++ b/app/Models/VmBackup.php @@ -0,0 +1,26 @@ + 'integer', + 'completed_at' => 'datetime', + ]; + } + + public function vm(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } +} diff --git a/app/Models/VmDevice.php b/app/Models/VmDevice.php new file mode 100644 index 0000000..3030191 --- /dev/null +++ b/app/Models/VmDevice.php @@ -0,0 +1,58 @@ + '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'); + } +} diff --git a/app/Models/VmFirewallRule.php b/app/Models/VmFirewallRule.php new file mode 100644 index 0000000..f49370c --- /dev/null +++ b/app/Models/VmFirewallRule.php @@ -0,0 +1,26 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + } + + public function vm(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } +} diff --git a/app/Models/VmMetric.php b/app/Models/VmMetric.php new file mode 100644 index 0000000..76d1073 --- /dev/null +++ b/app/Models/VmMetric.php @@ -0,0 +1,32 @@ + 'float', + 'mem' => 'integer', + 'maxmem' => 'integer', + 'disk' => 'integer', + 'maxdisk' => 'integer', + 'recorded_at' => 'datetime', + ]; + } + + public function vm(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } +} diff --git a/app/Models/VmSnapshot.php b/app/Models/VmSnapshot.php new file mode 100644 index 0000000..adad5a9 --- /dev/null +++ b/app/Models/VmSnapshot.php @@ -0,0 +1,26 @@ + 'boolean', + 'expires_at' => 'datetime', + ]; + } + + public function vm(): BelongsTo + { + return $this->belongsTo(Customer::class, 'customer_id'); + } +} diff --git a/app/Models/VmTemplate.php b/app/Models/VmTemplate.php new file mode 100644 index 0000000..e5a537f --- /dev/null +++ b/app/Models/VmTemplate.php @@ -0,0 +1,20 @@ + 'integer', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Models/VmidReservation.php b/app/Models/VmidReservation.php new file mode 100644 index 0000000..2cc7a04 --- /dev/null +++ b/app/Models/VmidReservation.php @@ -0,0 +1,36 @@ + '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); + } +} diff --git a/app/Models/WhmcsService.php b/app/Models/WhmcsService.php new file mode 100644 index 0000000..9c48e2c --- /dev/null +++ b/app/Models/WhmcsService.php @@ -0,0 +1,45 @@ + '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'); + } +} diff --git a/app/Policies/IpPoolPolicy.php b/app/Policies/IpPoolPolicy.php new file mode 100644 index 0000000..fd92f7c --- /dev/null +++ b/app/Policies/IpPoolPolicy.php @@ -0,0 +1,34 @@ +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; + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000..06cc7e0 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,28 @@ +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; + } +} diff --git a/app/Policies/VmPolicy.php b/app/Policies/VmPolicy.php new file mode 100644 index 0000000..50ede5b --- /dev/null +++ b/app/Policies/VmPolicy.php @@ -0,0 +1,39 @@ +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; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..c406b56 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,36 @@ +by($request->ip().'|'.$request->input('email')); + }); + + Gate::policy(Customer::class, VmPolicy::class); + Gate::policy(IpPool::class, IpPoolPolicy::class); + Gate::policy(User::class, UserPolicy::class); + } +} diff --git a/app/Providers/HostingServiceProvider.php b/app/Providers/HostingServiceProvider.php new file mode 100644 index 0000000..c2713f9 --- /dev/null +++ b/app/Providers/HostingServiceProvider.php @@ -0,0 +1,36 @@ +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()); + }); + } +} diff --git a/app/Services/Auth/TwoFactorService.php b/app/Services/Auth/TwoFactorService.php new file mode 100644 index 0000000..951aab7 --- /dev/null +++ b/app/Services/Auth/TwoFactorService.php @@ -0,0 +1,87 @@ +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; + } + } +} diff --git a/app/Services/Hosting/Backups/BackupService.php b/app/Services/Hosting/Backups/BackupService.php new file mode 100644 index 0000000..a5a0393 --- /dev/null +++ b/app/Services/Hosting/Backups/BackupService.php @@ -0,0 +1,73 @@ +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(); + } + } +} diff --git a/app/Services/Hosting/DTO/CustomerProvisionData.php b/app/Services/Hosting/DTO/CustomerProvisionData.php new file mode 100644 index 0000000..fb5ade9 --- /dev/null +++ b/app/Services/Hosting/DTO/CustomerProvisionData.php @@ -0,0 +1,74 @@ +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 !== ''; + } +} diff --git a/app/Services/Hosting/Firewall/FirewallService.php b/app/Services/Hosting/Firewall/FirewallService.php new file mode 100644 index 0000000..96c55e1 --- /dev/null +++ b/app/Services/Hosting/Firewall/FirewallService.php @@ -0,0 +1,33 @@ +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); + } + } +} diff --git a/app/Services/Hosting/Health/SystemHealthService.php b/app/Services/Hosting/Health/SystemHealthService.php new file mode 100644 index 0000000..1407ee7 --- /dev/null +++ b/app/Services/Hosting/Health/SystemHealthService.php @@ -0,0 +1,71 @@ + + */ + 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?)']; + } + } +} diff --git a/app/Services/Hosting/Iso/IsoUploadService.php b/app/Services/Hosting/Iso/IsoUploadService.php new file mode 100644 index 0000000..0bfaaa3 --- /dev/null +++ b/app/Services/Hosting/Iso/IsoUploadService.php @@ -0,0 +1,67 @@ +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; + } +} diff --git a/app/Services/Hosting/Metrics/MetricsCollectorService.php b/app/Services/Hosting/Metrics/MetricsCollectorService.php new file mode 100644 index 0000000..85e7b77 --- /dev/null +++ b/app/Services/Hosting/Metrics/MetricsCollectorService.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/app/Services/Hosting/Notifications/HostingNotificationService.php b/app/Services/Hosting/Notifications/HostingNotificationService.php new file mode 100644 index 0000000..189fe38 --- /dev/null +++ b/app/Services/Hosting/Notifications/HostingNotificationService.php @@ -0,0 +1,60 @@ +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()]); + } + } + } +} diff --git a/app/Services/Hosting/Plesk/PleskClient.php b/app/Services/Hosting/Plesk/PleskClient.php new file mode 100644 index 0000000..8648f54 --- /dev/null +++ b/app/Services/Hosting/Plesk/PleskClient.php @@ -0,0 +1,219 @@ +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'); + } + } +} diff --git a/app/Services/Hosting/Provisioning/DeprovisionService.php b/app/Services/Hosting/Provisioning/DeprovisionService.php new file mode 100644 index 0000000..647682b --- /dev/null +++ b/app/Services/Hosting/Provisioning/DeprovisionService.php @@ -0,0 +1,86 @@ + $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'), + ]); + } +} diff --git a/app/Services/Hosting/Provisioning/IpAddressAllocator.php b/app/Services/Hosting/Provisioning/IpAddressAllocator.php new file mode 100644 index 0000000..8fa11d9 --- /dev/null +++ b/app/Services/Hosting/Provisioning/IpAddressAllocator.php @@ -0,0 +1,162 @@ +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(); + } +} diff --git a/app/Services/Hosting/Provisioning/ProvisioningRollback.php b/app/Services/Hosting/Provisioning/ProvisioningRollback.php new file mode 100644 index 0000000..e5b111f --- /dev/null +++ b/app/Services/Hosting/Provisioning/ProvisioningRollback.php @@ -0,0 +1,69 @@ +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); + } + } +} diff --git a/app/Services/Hosting/Provisioning/ProvisioningService.php b/app/Services/Hosting/Provisioning/ProvisioningService.php new file mode 100644 index 0000000..daf6f97 --- /dev/null +++ b/app/Services/Hosting/Provisioning/ProvisioningService.php @@ -0,0 +1,262 @@ +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]); + } +} diff --git a/app/Services/Hosting/Provisioning/VmidReservationService.php b/app/Services/Hosting/Provisioning/VmidReservationService.php new file mode 100644 index 0000000..0c0cfb6 --- /dev/null +++ b/app/Services/Hosting/Provisioning/VmidReservationService.php @@ -0,0 +1,104 @@ +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', + ); + } +} diff --git a/app/Services/Hosting/Proxmox/ProxmoxClient.php b/app/Services/Hosting/Proxmox/ProxmoxClient.php new file mode 100644 index 0000000..a7915dc --- /dev/null +++ b/app/Services/Hosting/Proxmox/ProxmoxClient.php @@ -0,0 +1,591 @@ +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{{ $check['message'] }}
+| Name | Slug | VMID | |
|---|---|---|---|
| {{ $t->name }} | +{{ $t->slug }} | +{{ $t->proxmox_template_vmid }} | ++ Bearbeiten + + | +
Hosting Control Panel
+Admins müssen 2FA aktivieren. Scannen Sie den QR-Code mit Ihrer Authenticator-App.
+{{ $secret }}
+ +{{ $card['label'] }}
+{{ $card['value'] }}
+Keine IP-Pools konfiguriert.
+ @endforelse +{{ $vm->name }}
+{{ $vm->domain ?? 'Kein Traefik' }} · {{ $vm->ip_address ?? '—' }}
+Noch keine VMs.
+ @endforelse +{{ $pool->start_ip }} – {{ $pool->end_ip }}
+Gateway: {{ $pool->gateway ?? '—' }} /{{ $pool->cidr }}
+| VM | + @if(auth()->user()->isAdmin())Kunde | @endif +Private IP | +Public IP | +Pool | +
|---|---|---|---|---|
| {{ $vm->name }} | + @if(auth()->user()->isAdmin()){{ $vm->owner?->name }} | @endif +{{ $vm->ip_address ?? '—' }} | +{{ $vm->public_ip ?? '—' }} | +{{ $vm->ipPool?->name ?? '—' }} | +
| Keine Zuweisungen. | ||||
Max. {{ config('hosting.iso_upload.max_per_customer') }} ISO, {{ config('hosting.iso_upload.max_size_mb') }} MB, {{ config('hosting.iso_upload.retention_hours') }}h Aufbewahrung.
+ +| Name | +Rolle | +VMs | +Status | ++ | |
|---|---|---|---|---|---|
| {{ $user->name }} | +{{ $user->email }} | +{{ $user->role->label() }} | +{{ $user->vms_count }} | +{{ $user->is_active ? 'Aktiv' : 'Deaktiviert' }} | ++ Bearbeiten + | +
+ Hinweis: Der Browser muss Proxmox unter {{ parse_url(config('hosting.proxmox.console_ws_url') ?: config('hosting.proxmox.url'), PHP_URL_HOST) }} erreichen können. +
+ + diff --git a/resources/views/vms/create.blade.php b/resources/views/vms/create.blade.php new file mode 100644 index 0000000..e0c3c21 --- /dev/null +++ b/resources/views/vms/create.blade.php @@ -0,0 +1,98 @@ +@extends('layouts.app') +@section('title', 'VM erstellen') +@section('heading', 'Neue VM erstellen') + +@section('content') + + +@push('scripts') + +@endpush +@endsection diff --git a/resources/views/vms/edit.blade.php b/resources/views/vms/edit.blade.php new file mode 100644 index 0000000..674936d --- /dev/null +++ b/resources/views/vms/edit.blade.php @@ -0,0 +1,33 @@ +@extends('layouts.app') +@section('title', 'VM bearbeiten') +@section('heading', 'VM konfigurieren: '.$vm->name) + +@section('content') + +@endsection diff --git a/resources/views/vms/index.blade.php b/resources/views/vms/index.blade.php new file mode 100644 index 0000000..80bde4b --- /dev/null +++ b/resources/views/vms/index.blade.php @@ -0,0 +1,61 @@ +@extends('layouts.app') +@section('title', 'VMs') +@section('heading', 'Virtuelle Maschinen') + +@section('content') +| Name | + @if(auth()->user()->isAdmin())Kunde | @endif +VMID | +IP / Public | +Ressourcen | +Status | ++ |
|---|---|---|---|---|---|---|
| {{ $vm->name }} | + @if(auth()->user()->isAdmin()) +{{ $vm->owner?->name ?? '—' }} | + @endif +{{ $vm->vmid ?? '—' }} | +
+ {{ $vm->ip_address ?? '—' }}
+ @if($vm->public_ip) {{ $vm->public_ip }}@endif + |
+ {{ $vm->cpu }} vCPU · {{ $vm->ram }} MB · {{ $vm->disk }} GB | ++ @include('partials.status-badge', ['status' => $vm->status]) + @if($vm->proxmox_status) + ({{ $vm->proxmox_status }}) + @endif + | ++ Details + | +
| Keine VMs gefunden. | ||||||
{{ $vm->attached_iso }}
+ + @else +Keine ISO eingebunden.
+ @endif + +Nach dem Einbinden: VM neu starten und von CD booten.
+Status nicht abrufbar (Proxmox-Verbindung prüfen).
+ @endif +Noch keine Aktionen.
+ @endforelse +With so many options available to you,
we suggest you start with the following:
+ v{{ app()->version() }} + + View changelog + + +
+