initial commit

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

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Hosting\Proxmox\VmManagementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ConsoleValidateController extends Controller
{
public function __invoke(Request $request, string $token, VmManagementService $management): JsonResponse
{
$secret = config('hosting.console.proxy_secret');
if (! $secret || $request->header('X-Console-Proxy-Secret') !== $secret) {
return response()->json(['error' => 'forbidden'], 403);
}
$data = \Illuminate\Support\Facades\Cache::get($management->consoleCacheKey($token));
if (! $data) {
return response()->json(['error' => 'expired'], 410);
}
return response()->json([
'proxmox_ws_url' => $data['ws_url'],
'vmid' => $data['vmid'],
'node' => $data['node'],
]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Http\Requests\Whmcs\ProvisionServiceRequest;
use App\Jobs\ProvisionCustomerJob;
use App\Models\Customer;
use App\Models\HostingPlan;
use App\Models\User;
use App\Models\WhmcsService;
use App\Services\Hosting\Provisioning\DeprovisionService;
use App\Services\Hosting\Provisioning\VmidReservationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class WhmcsServiceController extends Controller
{
public function provision(ProvisionServiceRequest $request): JsonResponse
{
$plan = HostingPlan::query()->where('slug', $request->validated('plan_slug'))->firstOrFail();
$domain = $request->domain();
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
return response()->json(['error' => 'Domain already taken.'], 422);
}
$result = DB::transaction(function () use ($request, $plan, $domain) {
$user = User::query()->updateOrCreate(
['whmcs_client_id' => $request->integer('whmcs_client_id')],
[
'name' => $request->validated('client_name'),
'email' => $request->validated('client_email'),
'password' => Hash::make(Str::random(32)),
'role' => UserRole::Customer,
'is_active' => true,
],
);
$customer = Customer::query()->create([
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'name' => $request->validated('hostname'),
'domain' => $domain ?? 'direct-'.Str::slug($request->validated('hostname')).'-'.Str::lower(Str::random(6)).'.internal',
'behind_traefik' => $request->boolean('behind_traefik', true),
'provision_mode' => $request->validated('provision_mode'),
'attached_iso' => $request->validated('iso_volid'),
'cpu' => $plan->cpu,
'ram' => $plan->ram,
'disk' => $plan->disk,
'status' => 'pending',
'provisioning_step' => 'queued',
]);
$whmcsService = WhmcsService::query()->create([
'whmcs_service_id' => $request->integer('whmcs_service_id'),
'whmcs_client_id' => $request->integer('whmcs_client_id'),
'whmcs_order_id' => $request->input('whmcs_order_id'),
'customer_id' => $customer->id,
'user_id' => $user->id,
'hosting_plan_id' => $plan->id,
'status' => 'provisioning',
'config' => $request->only([
'provision_mode', 'template_slug', 'iso_volid', 'behind_traefik', 'subdomain',
]),
]);
$customer->update(['whmcs_service_id' => $whmcsService->id]);
$vmid = app(VmidReservationService::class)->reserveForCustomer($customer);
$customer->update(['vmid' => $vmid]);
ProvisionCustomerJob::dispatch($customer->id);
return compact('customer', 'user', 'whmcsService', 'vmid');
});
return response()->json([
'message' => 'Provisioning queued.',
'customer_id' => $result['customer']->id,
'vmid' => $result['vmid'],
'panel_url' => config('hosting.panel.url'),
], 202);
}
public function suspend(Request $request, int $whmcsServiceId): JsonResponse
{
$service = $this->resolveService($whmcsServiceId);
$customer = $service->customer;
if ($customer?->vmid) {
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->stopVM((int) $customer->vmid);
}
$service->update(['status' => 'suspended']);
$customer?->update(['status' => 'failed', 'provisioning_step' => 'suspended']);
return response()->json(['message' => 'Service suspended.']);
}
public function unsuspend(Request $request, int $whmcsServiceId): JsonResponse
{
$service = $this->resolveService($whmcsServiceId);
$customer = $service->customer;
if ($customer?->vmid) {
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->startVM((int) $customer->vmid);
$customer->update(['status' => 'active', 'provisioning_step' => 'completed']);
}
$service->update(['status' => 'active']);
return response()->json(['message' => 'Service unsuspended.']);
}
public function terminate(Request $request, int $whmcsServiceId, DeprovisionService $deprovision): JsonResponse
{
$service = $this->resolveService($whmcsServiceId);
$customer = $service->customer;
if ($customer) {
$deprovision->terminatePermanently($customer);
}
$service->update(['status' => 'terminated', 'customer_id' => null]);
return response()->json([
'message' => 'Service terminated. VMID will be released after configured retention.',
]);
}
public function status(int $whmcsServiceId): JsonResponse
{
$service = $this->resolveService($whmcsServiceId);
$service->load('customer');
return response()->json([
'whmcs_service_id' => $service->whmcs_service_id,
'status' => $service->status,
'customer' => $service->customer ? [
'id' => $service->customer->id,
'name' => $service->customer->name,
'vmid' => $service->customer->vmid,
'status' => $service->customer->status,
'provisioning_step' => $service->customer->provisioning_step,
'ip_address' => $service->customer->ip_address,
'domain' => $service->customer->domain,
] : null,
]);
}
private function resolveService(int $whmcsServiceId): WhmcsService
{
return WhmcsService::query()
->where('whmcs_service_id', $whmcsServiceId)
->firstOrFail();
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class LoginController extends Controller
{
public function create(): View
{
return view('auth.login');
}
public function store(Request $request): RedirectResponse
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
return back()->withErrors(['email' => 'Ungültige Anmeldedaten.'])->onlyInput('email');
}
$user = Auth::user();
if (! $user->is_active) {
Auth::logout();
return back()->withErrors(['email' => 'Ihr Konto ist deaktiviert.']);
}
$request->session()->regenerate();
$request->session()->forget('two_factor_passed');
return redirect()->intended(route('dashboard'));
}
public function destroy(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Services\Auth\TwoFactorService;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TwoFactorController extends Controller
{
public function setup(Request $request, TwoFactorService $twoFactor): View
{
$secret = $request->session()->get('two_factor_setup_secret');
if (! $secret) {
$secret = $twoFactor->generateSecret();
$request->session()->put('two_factor_setup_secret', $secret);
}
$qrUrl = $twoFactor->qrUrl($request->user(), $secret);
$writer = new Writer(new ImageRenderer(new RendererStyle(200), new SvgImageBackEnd));
$qrSvg = $writer->writeString($qrUrl);
return view('auth.two-factor-setup', [
'secret' => $secret,
'qrSvg' => $qrSvg,
]);
}
public function enable(Request $request, TwoFactorService $twoFactor): RedirectResponse
{
$request->validate(['code' => ['required', 'string', 'size:6']]);
$secret = $request->session()->pull('two_factor_setup_secret');
if (! $secret || ! $twoFactor->enable($request->user(), $secret, $request->input('code'))) {
return back()->withErrors(['code' => 'Ungültiger Code. Bitte erneut versuchen.']);
}
$request->session()->put('two_factor_passed', true);
return redirect()->route('dashboard')->with('success', 'Zwei-Faktor-Authentifizierung aktiviert.');
}
public function challenge(): View
{
return view('auth.two-factor-challenge');
}
public function verifyChallenge(Request $request, TwoFactorService $twoFactor): RedirectResponse
{
$request->validate(['code' => ['required', 'string']]);
if (! $twoFactor->verify($request->user(), $request->input('code'))) {
return back()->withErrors(['code' => 'Ungültiger Authentifizierungscode.']);
}
$request->session()->put('two_factor_passed', true);
return redirect()->intended(route('dashboard'));
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCustomerRequest;
use App\Jobs\ProvisionCustomerJob;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
class CustomerProvisioningController extends Controller
{
public function store(StoreCustomerRequest $request): JsonResponse
{
$domain = $request->domain();
$customer = Customer::query()->create([
'name' => $request->validated('name'),
'domain' => $domain,
'cpu' => $request->integer('cpu', config('hosting.defaults.cpu')),
'ram' => $request->integer('ram', config('hosting.defaults.ram')),
'disk' => $request->integer('disk', config('hosting.defaults.disk')),
'status' => 'pending',
'provisioning_step' => 'queued',
]);
ProvisionCustomerJob::dispatch($customer->id);
return response()->json([
'message' => 'Provisioning started.',
'customer' => $customer,
], 202);
}
public function show(Customer $customer): JsonResponse
{
return response()->json(['customer' => $customer]);
}
public function index(): JsonResponse
{
return response()->json([
'customers' => Customer::query()->latest()->get(),
]);
}
public function destroy(Customer $customer): JsonResponse
{
if ($customer->status === 'pending' && $customer->provisioning_step === 'queued') {
$customer->delete();
return response()->json(['message' => 'Queued customer removed.']);
}
return response()->json([
'message' => 'Use deprovision endpoint for active customers.',
], 422);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Web\Admin;
use App\Http\Controllers\Controller;
use App\Services\Hosting\Health\SystemHealthService;
use Illuminate\View\View;
class SystemHealthController extends Controller
{
public function __invoke(SystemHealthService $health): View
{
return view('admin.health', [
'checks' => $health->checks(),
]);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Web\Admin;
use App\Http\Controllers\Controller;
use App\Models\VmTemplate;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VmTemplateController extends Controller
{
public function index(): View
{
$templates = VmTemplate::query()->orderBy('name')->get();
return view('admin.templates.index', compact('templates'));
}
public function create(): View
{
return view('admin.templates.create');
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug'],
'name' => ['required', 'string', 'max:120'],
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
'os_family' => ['nullable', 'string', 'max:32'],
'is_active' => ['boolean'],
]);
VmTemplate::query()->create([
...$data,
'is_active' => $request->boolean('is_active', true),
]);
return redirect()->route('admin.templates.index')->with('success', 'Template angelegt.');
}
public function edit(VmTemplate $template): View
{
return view('admin.templates.edit', compact('template'));
}
public function update(Request $request, VmTemplate $template): RedirectResponse
{
$data = $request->validate([
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug,'.$template->id],
'name' => ['required', 'string', 'max:120'],
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
'os_family' => ['nullable', 'string', 'max:32'],
'is_active' => ['boolean'],
]);
$template->update([
...$data,
'is_active' => $request->boolean('is_active', true),
]);
return redirect()->route('admin.templates.index')->with('success', 'Template aktualisiert.');
}
public function destroy(VmTemplate $template): RedirectResponse
{
$template->delete();
return redirect()->route('admin.templates.index')->with('success', 'Template gelöscht.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function __invoke(Request $request): View
{
$user = $request->user();
$vmQuery = Customer::query()->forUser($user);
$stats = [
'vms_total' => (clone $vmQuery)->count(),
'vms_active' => (clone $vmQuery)->where('status', 'active')->count(),
'vms_pending' => (clone $vmQuery)->where('status', 'pending')->count(),
'vms_failed' => (clone $vmQuery)->where('status', 'failed')->count(),
];
$pools = IpPool::query()
->where('is_active', true)
->orderBy('type')
->get();
$recentVms = Customer::query()
->forUser($user)
->with(['owner', 'ipPool'])
->latest()
->limit(8)
->get();
return view('dashboard', [
'stats' => $stats,
'pools' => $pools,
'recentVms' => $recentVms,
'usersCount' => $user->isAdmin() ? User::query()->count() : null,
]);
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\IpPoolType;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\IpPool;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class IpPoolController extends Controller
{
public function index(Request $request): View
{
$this->authorize('viewAny', IpPool::class);
$pools = IpPool::query()
->withCount(['vms' => fn ($q) => $q->whereIn('status', ['pending', 'active'])])
->orderBy('type')
->orderBy('name')
->get()
->map(function (IpPool $pool) {
$pool->setAttribute('usage_percent', $pool->totalIps() > 0
? round(($pool->usedIpsCount() / $pool->totalIps()) * 100, 1)
: 0);
return $pool;
});
$assignments = Customer::query()
->forUser($request->user())
->with(['owner', 'ipPool'])
->whereIn('status', ['pending', 'active'])
->where(fn ($q) => $q->whereNotNull('ip_address')->orWhereNotNull('public_ip'))
->orderBy('name')
->get();
return view('ip-pools.index', compact('pools', 'assignments'));
}
public function create(): View
{
$this->authorize('create', IpPool::class);
return view('ip-pools.create', ['types' => IpPoolType::cases()]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', IpPool::class);
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
'type' => ['required', Rule::enum(IpPoolType::class)],
'start_ip' => ['required', 'ip'],
'end_ip' => ['required', 'ip'],
'gateway' => ['nullable', 'ip'],
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
'description' => ['nullable', 'string', 'max:500'],
'is_active' => ['boolean'],
]);
if (ip2long($data['start_ip']) > ip2long($data['end_ip'])) {
return back()->withErrors(['end_ip' => 'End-IP muss größer als Start-IP sein.'])->withInput();
}
IpPool::query()->create([
...$data,
'is_active' => $request->boolean('is_active', true),
]);
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool erstellt.');
}
public function edit(IpPool $ipPool): View
{
$this->authorize('update', $ipPool);
return view('ip-pools.edit', [
'pool' => $ipPool,
'types' => IpPoolType::cases(),
]);
}
public function update(Request $request, IpPool $ipPool): RedirectResponse
{
$this->authorize('update', $ipPool);
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
'type' => ['required', Rule::enum(IpPoolType::class)],
'start_ip' => ['required', 'ip'],
'end_ip' => ['required', 'ip'],
'gateway' => ['nullable', 'ip'],
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
'description' => ['nullable', 'string', 'max:500'],
'is_active' => ['boolean'],
]);
$ipPool->update([
...$data,
'is_active' => $request->boolean('is_active'),
]);
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool aktualisiert.');
}
public function destroy(IpPool $ipPool): RedirectResponse
{
$this->authorize('delete', $ipPool);
if ($ipPool->vms()->whereIn('status', ['pending', 'active'])->exists()) {
return back()->withErrors(['pool' => 'Pool wird noch von aktiven VMs verwendet.']);
}
$ipPool->delete();
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool gelöscht.');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\CustomerIsoUpload;
use App\Services\Hosting\Iso\IsoUploadService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class IsoUploadController extends Controller
{
public function index(Request $request): View
{
$uploads = CustomerIsoUpload::query()
->where('user_id', $request->user()->id)
->latest()
->get();
return view('iso-uploads.index', compact('uploads'));
}
public function store(Request $request, IsoUploadService $service): RedirectResponse
{
$request->validate([
'iso' => ['required', 'file', 'mimes:iso', 'max:'.((int) config('hosting.iso_upload.max_size_mb', 10240) * 1024)],
]);
try {
$service->upload($request->user(), $request->file('iso'));
} catch (\Throwable $e) {
return back()->withErrors(['iso' => $e->getMessage()]);
}
return redirect()->route('iso-uploads.index')->with('success', 'ISO hochgeladen.');
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\UserRole;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class UserController extends Controller
{
public function index(): View
{
$this->authorize('viewAny', User::class);
$users = User::query()->withCount('vms')->orderBy('name')->paginate(20);
return view('users.index', compact('users'));
}
public function create(): View
{
$this->authorize('create', User::class);
return view('users.create', ['roles' => UserRole::cases()]);
}
public function store(Request $request): RedirectResponse
{
$this->authorize('create', User::class);
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'role' => ['required', Rule::enum(UserRole::class)],
'is_active' => ['boolean'],
]);
User::query()->create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'role' => $data['role'],
'is_active' => $request->boolean('is_active', true),
]);
return redirect()->route('users.index')->with('success', 'Benutzer erstellt.');
}
public function edit(User $user): View
{
$this->authorize('update', $user);
return view('users.edit', [
'user' => $user,
'roles' => UserRole::cases(),
]);
}
public function update(Request $request, User $user): RedirectResponse
{
$this->authorize('update', $user);
$data = $request->validate([
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'password' => ['nullable', 'confirmed', Password::defaults()],
'role' => ['required', Rule::enum(UserRole::class)],
'is_active' => ['boolean'],
]);
$user->fill([
'name' => $data['name'],
'email' => $data['email'],
'role' => $data['role'],
'is_active' => $request->boolean('is_active'),
]);
if (! empty($data['password'])) {
$user->password = Hash::make($data['password']);
}
$user->save();
return redirect()->route('users.index')->with('success', 'Benutzer aktualisiert.');
}
public function destroy(User $user): RedirectResponse
{
$this->authorize('delete', $user);
if ($user->vms()->whereIn('status', ['pending', 'active'])->exists()) {
return back()->withErrors(['user' => 'Benutzer hat noch aktive VMs.']);
}
$user->delete();
return redirect()->route('users.index')->with('success', 'Benutzer gelöscht.');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\Hosting\Backups\BackupService;
use Illuminate\Http\RedirectResponse;
class VmBackupController extends Controller
{
public function store(Customer $vm, BackupService $backups): RedirectResponse
{
$this->authorize('manage', $vm);
try {
$backups->start($vm, auth()->user());
} catch (\Throwable $e) {
return back()->withErrors(['backup' => $e->getMessage()]);
}
return back()->with('success', 'Backup gestartet.');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\Hosting\Proxmox\VmManagementService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class VmConsoleController extends Controller
{
public function create(Request $request, Customer $vm, VmManagementService $management): View
{
$this->authorize('manage', $vm);
try {
$session = $management->createConsoleSession($vm, $request->user());
} catch (\Throwable $e) {
return view('vms.console-error', [
'vm' => $vm,
'error' => $e->getMessage(),
]);
}
return view('vms.console', [
'vm' => $vm,
'token' => $session['token'],
'wsUrl' => $session['ws_url'],
'expiresAt' => $session['expires_at'],
]);
}
public function show(Request $request, string $token, VmManagementService $management): View
{
$session = $management->getConsoleSession($token, $request->user());
$vm = Customer::query()->findOrFail($session['customer_id']);
$this->authorize('view', $vm);
return view('vms.console', [
'vm' => $vm,
'token' => $token,
'wsUrl' => $session['ws_url'],
'expiresAt' => now()->addMinutes(5)->timestamp,
]);
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreVmRequest;
use App\Http\Requests\UpdateVmRequest;
use App\Jobs\ProvisionCustomerJob;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\User;
use App\Models\VmDevice;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use App\Services\Hosting\Provisioning\DeprovisionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class VmController extends Controller
{
public function index(Request $request): View
{
$this->authorize('viewAny', Customer::class);
$vms = Customer::query()
->forUser($request->user())
->with(['owner', 'ipPool'])
->when($request->query('status'), fn ($q, $status) => $q->where('status', $status))
->latest()
->paginate(15)
->withQueryString();
return view('vms.index', compact('vms'));
}
public function create(Request $request): View
{
$this->authorize('create', Customer::class);
$isos = [];
try {
$isos = app(ProxmoxClient::class)->listIsos();
} catch (\Throwable) {
// Proxmox nicht erreichbar Formular bleibt nutzbar
}
return view('vms.create', [
'privatePools' => IpPool::query()->where('type', 'private')->where('is_active', true)->get(),
'customers' => $request->user()->isAdmin()
? User::query()->where('role', 'customer')->orderBy('name')->get()
: collect(),
'deviceTypes' => VmDevice::typesFor($request->user()),
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
'isos' => $isos,
]);
}
public function store(StoreVmRequest $request): RedirectResponse
{
$domain = $request->domain();
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
return back()->withErrors(['subdomain' => 'Diese Subdomain ist bereits vergeben.'])->withInput();
}
if (! $domain) {
$domain = 'direct-'.Str::slug($request->validated('name')).'-'.Str::lower(Str::random(6)).'.internal';
}
$vm = Customer::query()->create([
'user_id' => $request->ownerId(),
'name' => $request->validated('name'),
'domain' => $domain,
'behind_traefik' => $request->boolean('behind_traefik'),
'ip_pool_id' => $request->input('ip_pool_id'),
'cpu' => $request->integer('cpu'),
'ram' => $request->integer('ram'),
'disk' => $request->integer('disk'),
'attached_iso' => $request->validated('install_iso'),
'status' => 'pending',
'provisioning_step' => 'queued',
]);
$this->syncDevices($vm, $request->input('devices', []));
ProvisionCustomerJob::dispatch($vm->id);
return redirect()
->route('vms.show', $vm)
->with('success', 'VM-Provisioning wurde gestartet.');
}
public function show(Request $request, Customer $vm): View
{
$this->authorize('view', $vm);
$vm->load([
'owner', 'ipPool', 'devices',
'snapshots' => fn ($q) => $q->latest(),
'backups' => fn ($q) => $q->latest()->limit(10),
'firewallRules',
'metrics' => fn ($q) => $q->limit(48),
'activityLogs' => fn ($q) => $q->limit(15)->with('user'),
]);
$isos = [];
$liveStatus = null;
if ($vm->vmid && $vm->status === 'active') {
try {
$proxmox = app(ProxmoxClient::class);
$isos = $proxmox->listIsos();
} catch (\Throwable) {
//
}
try {
$proxmox ??= app(ProxmoxClient::class);
$liveStatus = $proxmox->normalizeLiveStatus($proxmox->getVMStatus((int) $vm->vmid));
$vm->update([
'proxmox_status' => $liveStatus['status'],
'proxmox_uptime' => $liveStatus['uptime'],
'proxmox_status_at' => now(),
]);
} catch (\Throwable) {
//
}
}
return view('vms.show', compact('vm', 'isos', 'liveStatus'));
}
public function edit(Request $request, Customer $vm): View
{
$this->authorize('update', $vm);
$vm->load('devices');
return view('vms.edit', [
'vm' => $vm,
'deviceTypes' => VmDevice::typesFor($request->user()),
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
]);
}
public function update(UpdateVmRequest $request, Customer $vm): RedirectResponse
{
$vm->update($request->only(['name', 'cpu', 'ram', 'disk']));
if ($request->has('devices')) {
$vm->devices()->delete();
$this->syncDevices($vm, $request->input('devices', []));
}
if ($vm->vmid && $vm->status === 'active') {
try {
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
->updateVmResources((int) $vm->vmid, $vm->cpu, $vm->ram, $vm->disk);
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
->applyDevices((int) $vm->vmid, $vm->devices()->get());
} catch (\Throwable $e) {
return back()->with('warning', 'DB gespeichert, Proxmox-Update fehlgeschlagen: '.$e->getMessage());
}
}
return redirect()->route('vms.show', $vm)->with('success', 'VM-Konfiguration gespeichert.');
}
public function destroy(Request $request, Customer $vm, DeprovisionService $deprovision, \App\Services\Hosting\Snapshots\SnapshotService $snapshots): RedirectResponse
{
$this->authorize('delete', $vm);
if ($vm->vmid && $vm->status === 'active') {
try {
$snapshots->autoBeforeDestructive($vm, $request->user(), 'delete');
} catch (\Throwable) {
//
}
}
if ($vm->status === 'pending' && $vm->provisioning_step === 'queued') {
if ($vm->vmid) {
app(\App\Services\Hosting\Provisioning\VmidReservationService::class)
->scheduleRelease((int) $vm->vmid, $vm);
}
$vm->devices()->delete();
$vm->delete();
return redirect()->route('vms.index')->with('success', 'VM-Eintrag gelöscht.');
}
$deprovision->removeVmOnly($vm, $request->user());
return redirect()->route('vms.index')->with('success', 'VM wurde entfernt. VMID wird nach 48h freigegeben.');
}
private function syncDevices(Customer $vm, array $devices): void
{
$allowed = array_keys(VmDevice::typesFor(auth()->user()));
foreach ($devices as $index => $device) {
if (empty($device['type']) || ! in_array($device['type'], $allowed, true)) {
continue;
}
$vm->devices()->create([
'type' => $device['type'],
'slot' => $device['slot'] ?? null,
'config' => $device['config'] ?? [],
'sort_order' => $index,
]);
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\Hosting\Firewall\FirewallService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class VmFirewallController extends Controller
{
public function store(Request $request, Customer $vm, FirewallService $firewall): RedirectResponse
{
$this->authorize('manage', $vm);
$data = $request->validate([
'direction' => ['required', Rule::in(['in', 'out'])],
'action' => ['required', Rule::in(['ACCEPT', 'DROP', 'REJECT'])],
'protocol' => ['required', Rule::in(['tcp', 'udp', 'icmp'])],
'port' => ['nullable', 'string', 'max:32'],
'source' => ['nullable', 'string', 'max:64'],
]);
$vm->firewallRules()->create([
...$data,
'sort_order' => $vm->firewallRules()->count(),
]);
try {
$firewall->syncToProxmox($vm);
} catch (\Throwable $e) {
return back()->with('warning', 'Regel gespeichert, Proxmox-Sync fehlgeschlagen: '.$e->getMessage());
}
return back()->with('success', 'Firewall-Regel hinzugefügt.');
}
public function destroy(Customer $vm, int $rule, FirewallService $firewall): RedirectResponse
{
$this->authorize('manage', $vm);
$vm->firewallRules()->whereKey($rule)->delete();
try {
$firewall->syncToProxmox($vm);
} catch (\Throwable) {
//
}
return back()->with('success', 'Regel entfernt.');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\Hosting\Proxmox\ProxmoxClient;
use App\Services\Hosting\Proxmox\VmManagementService;
use App\Services\Hosting\Snapshots\SnapshotService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class VmIsoController extends Controller
{
public function store(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
{
$this->authorize('manage', $vm);
$data = $request->validate([
'iso_volid' => ['required', 'string', 'max:255'],
]);
$snapshots->autoBeforeDestructive($vm, $request->user(), 'iso-mount');
try {
$management->mountIso($vm, $data['iso_volid'], $request->user());
} catch (\Throwable $e) {
return back()->withErrors(['iso' => $e->getMessage()]);
}
return back()->with('success', 'ISO wurde eingebunden. VM ggf. neu starten, um vom Installationsmedium zu booten.');
}
public function destroy(Request $request, Customer $vm, VmManagementService $management): RedirectResponse
{
$this->authorize('manage', $vm);
try {
$management->unmountIso($vm, $request->user());
} catch (\Throwable $e) {
return back()->withErrors(['iso' => $e->getMessage()]);
}
return back()->with('success', 'ISO wurde entfernt.');
}
public function index(ProxmoxClient $proxmox): \Illuminate\Http\JsonResponse
{
try {
return response()->json(['isos' => $proxmox->listIsos()]);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 502);
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Web;
use App\Enums\VmPowerAction;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\Hosting\Proxmox\VmManagementService;
use App\Services\Hosting\Snapshots\SnapshotService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class VmPowerController extends Controller
{
public function __invoke(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
{
$this->authorize('manage', $vm);
$action = VmPowerAction::from($request->validate([
'action' => ['required', Rule::enum(VmPowerAction::class)],
])['action']);
if (in_array($action, [VmPowerAction::Stop, VmPowerAction::Reset, VmPowerAction::Reboot], true)) {
$snapshots->autoBeforeDestructive($vm, $request->user(), 'power-'.$action->value);
}
try {
$management->power($vm, $action, $request->user());
} catch (\Throwable $e) {
return back()->withErrors(['power' => $e->getMessage()]);
}
return back()->with('success', $action->label().' wurde ausgeführt.');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\Hosting\Reinstall\ReinstallService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class VmReinstallController extends Controller
{
public function __invoke(Request $request, Customer $vm, ReinstallService $reinstall): RedirectResponse
{
$this->authorize('manage', $vm);
$request->validate(['confirm' => ['required', 'in:REINSTALL']]);
try {
$reinstall->reinstall($vm, $request->user());
} catch (\Throwable $e) {
return back()->withErrors(['reinstall' => $e->getMessage()]);
}
return redirect()->route('vms.show', $vm)->with('success', 'Neuinstallation gestartet.');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\VmSnapshot;
use App\Services\Hosting\Snapshots\SnapshotService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class VmSnapshotController extends Controller
{
public function store(Request $request, Customer $vm, SnapshotService $snapshots): RedirectResponse
{
$this->authorize('manage', $vm);
$request->validate(['label' => ['nullable', 'string', 'max:64']]);
try {
$snapshots->create($vm, $request->user(), false, $request->input('label'));
} catch (\Throwable $e) {
return back()->withErrors(['snapshot' => $e->getMessage()]);
}
return back()->with('success', 'Snapshot erstellt.');
}
public function rollback(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
{
$this->authorize('manage', $vm);
abort_unless($snapshot->customer_id === $vm->id, 404);
try {
$snapshots->rollback($vm, $snapshot);
} catch (\Throwable $e) {
return back()->withErrors(['snapshot' => $e->getMessage()]);
}
return back()->with('success', 'Snapshot wiederhergestellt.');
}
public function destroy(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
{
$this->authorize('manage', $vm);
abort_unless($snapshot->customer_id === $vm->id, 404);
try {
$snapshots->delete($vm, $snapshot);
} catch (\Throwable $e) {
return back()->withErrors(['snapshot' => $e->getMessage()]);
}
return back()->with('success', 'Snapshot gelöscht.');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Services\Hosting\Proxmox\VmManagementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VmStatusController extends Controller
{
public function __invoke(Request $request, Customer $vm, VmManagementService $management): JsonResponse
{
$this->authorize('view', $vm);
if (! $vm->vmid || $vm->status !== 'active') {
return response()->json([
'status' => $vm->status,
'proxmox' => null,
'message' => 'VM nicht bereit',
]);
}
try {
$live = $management->refreshLiveStatus($vm);
return response()->json([
'status' => $vm->status,
'proxmox' => $live,
'attached_iso' => $vm->attached_iso,
'refreshed_at' => $vm->proxmox_status_at?->toIso8601String(),
]);
} catch (\Throwable $e) {
return response()->json([
'status' => $vm->status,
'proxmox' => null,
'error' => $e->getMessage(),
], 502);
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use App\Services\Auth\TwoFactorService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTwoFactorVerified
{
public function __construct(private readonly TwoFactorService $twoFactor) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
if ($this->twoFactor->mustSetup($user) && ! $request->routeIs('two-factor.*')) {
return redirect()->route('two-factor.setup');
}
if ($this->twoFactor->isEnabled($user) && ! $request->session()->get('two_factor_passed')) {
if (! $request->routeIs('two-factor.challenge', 'two-factor.challenge.store', 'logout')) {
return redirect()->route('two-factor.challenge');
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->isAdmin()) {
abort(403, 'Zugriff nur für Administratoren.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class VerifyWhmcsRequest
{
public function handle(Request $request, Closure $next): Response
{
if (! config('hosting.whmcs.enabled', false)) {
abort(503, 'WHMCS integration is disabled.');
}
$secret = config('hosting.whmcs.api_secret');
if (empty($secret)) {
abort(500, 'WHMCS API secret is not configured.');
}
$allowedIps = config('hosting.whmcs.allowed_ips', []);
if ($allowedIps !== [] && ! in_array($request->ip(), $allowedIps, true)) {
abort(403, 'IP not allowed.');
}
$timestamp = (int) $request->header('X-Whmcs-Timestamp', 0);
$signature = (string) $request->header('X-Whmcs-Signature', '');
$window = (int) config('hosting.whmcs.replay_window_seconds', 300);
if ($timestamp === 0 || $signature === '') {
abort(401, 'Missing WHMCS signature headers.');
}
if (abs(time() - $timestamp) > $window) {
abort(401, 'Request timestamp expired.');
}
$payload = $timestamp.'.'.$request->getContent();
$expected = hash_hmac('sha256', $payload, $secret);
if (! hash_equals($expected, $signature)) {
abort(401, 'Invalid WHMCS signature.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCustomerRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$baseDomain = config('hosting.plesk.base_domain');
return [
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
'subdomain' => [
'required',
'string',
'max:63',
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
function (string $attribute, mixed $value, \Closure $fail) use ($baseDomain): void {
$domain = strtolower((string) $value).'.'.$baseDomain;
if (\App\Models\Customer::query()->where('domain', $domain)->exists()) {
$fail("The domain {$domain} is already registered.");
}
},
],
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
];
}
public function domain(): string
{
return strtolower($this->validated('subdomain')).'.'.config('hosting.plesk.base_domain');
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Requests;
use App\Models\Customer;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreVmRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Customer::class);
}
public function rules(): array
{
$baseDomain = config('hosting.plesk.base_domain');
return [
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
'subdomain' => [
Rule::requiredIf(fn () => $this->boolean('behind_traefik')),
'nullable',
'string',
'max:63',
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
],
'behind_traefik' => ['boolean'],
'user_id' => [
Rule::requiredIf(fn () => $this->user()->isAdmin()),
'nullable',
'exists:users,id',
],
'ip_pool_id' => ['nullable', 'exists:ip_pools,id'],
'cpu' => ['required', 'integer', 'min:1', 'max:32'],
'ram' => ['required', 'integer', 'min:512', 'max:131072'],
'disk' => ['required', 'integer', 'min:10', 'max:2048'],
'devices' => ['nullable', 'array'],
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
'devices.*.slot' => ['nullable', 'string', 'max:32'],
'devices.*.config' => ['nullable', 'array'],
'install_iso' => ['nullable', 'string', 'max:255'],
];
}
public function domain(): ?string
{
if (! $this->boolean('behind_traefik') || ! $this->filled('subdomain')) {
return null;
}
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
}
public function ownerId(): int
{
if ($this->user()->isAdmin() && $this->filled('user_id')) {
return (int) $this->input('user_id');
}
return $this->user()->id;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateVmRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('vm'));
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
'devices' => ['nullable', 'array'],
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
'devices.*.slot' => ['nullable', 'string', 'max:32'],
'devices.*.config' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests\Whmcs;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProvisionServiceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'whmcs_service_id' => ['required', 'integer', 'unique:whmcs_services,whmcs_service_id'],
'whmcs_client_id' => ['required', 'integer'],
'whmcs_order_id' => ['nullable', 'integer'],
'client_email' => ['required', 'email', 'max:255'],
'client_name' => ['required', 'string', 'max:100'],
'plan_slug' => ['required', 'string', 'exists:hosting_plans,slug'],
'hostname' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
'subdomain' => [
Rule::requiredIf(fn () => $this->boolean('behind_traefik', true)),
'nullable',
'string',
'max:63',
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
],
'behind_traefik' => ['boolean'],
'provision_mode' => ['required', Rule::in(['template', 'iso', 'empty'])],
'template_slug' => [
Rule::requiredIf(fn () => $this->input('provision_mode') === 'template'),
'nullable',
'string',
'max:64',
],
'iso_volid' => [
Rule::requiredIf(fn () => $this->input('provision_mode') === 'iso'),
'nullable',
'string',
'max:255',
],
];
}
public function domain(): ?string
{
if (! $this->boolean('behind_traefik', true) || ! $this->filled('subdomain')) {
return null;
}
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
}
}