initial commit
This commit is contained in:
30
app/Http/Controllers/Api/ConsoleValidateController.php
Normal file
30
app/Http/Controllers/Api/ConsoleValidateController.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ConsoleValidateController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $token, VmManagementService $management): JsonResponse
|
||||
{
|
||||
$secret = config('hosting.console.proxy_secret');
|
||||
if (! $secret || $request->header('X-Console-Proxy-Secret') !== $secret) {
|
||||
return response()->json(['error' => 'forbidden'], 403);
|
||||
}
|
||||
|
||||
$data = \Illuminate\Support\Facades\Cache::get($management->consoleCacheKey($token));
|
||||
if (! $data) {
|
||||
return response()->json(['error' => 'expired'], 410);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'proxmox_ws_url' => $data['ws_url'],
|
||||
'vmid' => $data['vmid'],
|
||||
'node' => $data['node'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
162
app/Http/Controllers/Api/WhmcsServiceController.php
Normal file
162
app/Http/Controllers/Api/WhmcsServiceController.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Whmcs\ProvisionServiceRequest;
|
||||
use App\Jobs\ProvisionCustomerJob;
|
||||
use App\Models\Customer;
|
||||
use App\Models\HostingPlan;
|
||||
use App\Models\User;
|
||||
use App\Models\WhmcsService;
|
||||
use App\Services\Hosting\Provisioning\DeprovisionService;
|
||||
use App\Services\Hosting\Provisioning\VmidReservationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class WhmcsServiceController extends Controller
|
||||
{
|
||||
public function provision(ProvisionServiceRequest $request): JsonResponse
|
||||
{
|
||||
$plan = HostingPlan::query()->where('slug', $request->validated('plan_slug'))->firstOrFail();
|
||||
$domain = $request->domain();
|
||||
|
||||
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
|
||||
return response()->json(['error' => 'Domain already taken.'], 422);
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($request, $plan, $domain) {
|
||||
$user = User::query()->updateOrCreate(
|
||||
['whmcs_client_id' => $request->integer('whmcs_client_id')],
|
||||
[
|
||||
'name' => $request->validated('client_name'),
|
||||
'email' => $request->validated('client_email'),
|
||||
'password' => Hash::make(Str::random(32)),
|
||||
'role' => UserRole::Customer,
|
||||
'is_active' => true,
|
||||
],
|
||||
);
|
||||
|
||||
$customer = Customer::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'hosting_plan_id' => $plan->id,
|
||||
'name' => $request->validated('hostname'),
|
||||
'domain' => $domain ?? 'direct-'.Str::slug($request->validated('hostname')).'-'.Str::lower(Str::random(6)).'.internal',
|
||||
'behind_traefik' => $request->boolean('behind_traefik', true),
|
||||
'provision_mode' => $request->validated('provision_mode'),
|
||||
'attached_iso' => $request->validated('iso_volid'),
|
||||
'cpu' => $plan->cpu,
|
||||
'ram' => $plan->ram,
|
||||
'disk' => $plan->disk,
|
||||
'status' => 'pending',
|
||||
'provisioning_step' => 'queued',
|
||||
]);
|
||||
|
||||
$whmcsService = WhmcsService::query()->create([
|
||||
'whmcs_service_id' => $request->integer('whmcs_service_id'),
|
||||
'whmcs_client_id' => $request->integer('whmcs_client_id'),
|
||||
'whmcs_order_id' => $request->input('whmcs_order_id'),
|
||||
'customer_id' => $customer->id,
|
||||
'user_id' => $user->id,
|
||||
'hosting_plan_id' => $plan->id,
|
||||
'status' => 'provisioning',
|
||||
'config' => $request->only([
|
||||
'provision_mode', 'template_slug', 'iso_volid', 'behind_traefik', 'subdomain',
|
||||
]),
|
||||
]);
|
||||
|
||||
$customer->update(['whmcs_service_id' => $whmcsService->id]);
|
||||
|
||||
$vmid = app(VmidReservationService::class)->reserveForCustomer($customer);
|
||||
$customer->update(['vmid' => $vmid]);
|
||||
|
||||
ProvisionCustomerJob::dispatch($customer->id);
|
||||
|
||||
return compact('customer', 'user', 'whmcsService', 'vmid');
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Provisioning queued.',
|
||||
'customer_id' => $result['customer']->id,
|
||||
'vmid' => $result['vmid'],
|
||||
'panel_url' => config('hosting.panel.url'),
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function suspend(Request $request, int $whmcsServiceId): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$customer = $service->customer;
|
||||
|
||||
if ($customer?->vmid) {
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->stopVM((int) $customer->vmid);
|
||||
}
|
||||
|
||||
$service->update(['status' => 'suspended']);
|
||||
$customer?->update(['status' => 'failed', 'provisioning_step' => 'suspended']);
|
||||
|
||||
return response()->json(['message' => 'Service suspended.']);
|
||||
}
|
||||
|
||||
public function unsuspend(Request $request, int $whmcsServiceId): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$customer = $service->customer;
|
||||
|
||||
if ($customer?->vmid) {
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->startVM((int) $customer->vmid);
|
||||
$customer->update(['status' => 'active', 'provisioning_step' => 'completed']);
|
||||
}
|
||||
|
||||
$service->update(['status' => 'active']);
|
||||
|
||||
return response()->json(['message' => 'Service unsuspended.']);
|
||||
}
|
||||
|
||||
public function terminate(Request $request, int $whmcsServiceId, DeprovisionService $deprovision): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$customer = $service->customer;
|
||||
|
||||
if ($customer) {
|
||||
$deprovision->terminatePermanently($customer);
|
||||
}
|
||||
|
||||
$service->update(['status' => 'terminated', 'customer_id' => null]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service terminated. VMID will be released after configured retention.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function status(int $whmcsServiceId): JsonResponse
|
||||
{
|
||||
$service = $this->resolveService($whmcsServiceId);
|
||||
$service->load('customer');
|
||||
|
||||
return response()->json([
|
||||
'whmcs_service_id' => $service->whmcs_service_id,
|
||||
'status' => $service->status,
|
||||
'customer' => $service->customer ? [
|
||||
'id' => $service->customer->id,
|
||||
'name' => $service->customer->name,
|
||||
'vmid' => $service->customer->vmid,
|
||||
'status' => $service->customer->status,
|
||||
'provisioning_step' => $service->customer->provisioning_step,
|
||||
'ip_address' => $service->customer->ip_address,
|
||||
'domain' => $service->customer->domain,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveService(int $whmcsServiceId): WhmcsService
|
||||
{
|
||||
return WhmcsService::query()
|
||||
->where('whmcs_service_id', $whmcsServiceId)
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Auth/LoginController.php
Normal file
50
app/Http/Controllers/Auth/LoginController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
return back()->withErrors(['email' => 'Ungültige Anmeldedaten.'])->onlyInput('email');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user->is_active) {
|
||||
Auth::logout();
|
||||
|
||||
return back()->withErrors(['email' => 'Ihr Konto ist deaktiviert.']);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('two_factor_passed');
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Auth/TwoFactorController.php
Normal file
65
app/Http/Controllers/Auth/TwoFactorController.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Auth\TwoFactorService;
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class TwoFactorController extends Controller
|
||||
{
|
||||
public function setup(Request $request, TwoFactorService $twoFactor): View
|
||||
{
|
||||
$secret = $request->session()->get('two_factor_setup_secret');
|
||||
if (! $secret) {
|
||||
$secret = $twoFactor->generateSecret();
|
||||
$request->session()->put('two_factor_setup_secret', $secret);
|
||||
}
|
||||
|
||||
$qrUrl = $twoFactor->qrUrl($request->user(), $secret);
|
||||
$writer = new Writer(new ImageRenderer(new RendererStyle(200), new SvgImageBackEnd));
|
||||
$qrSvg = $writer->writeString($qrUrl);
|
||||
|
||||
return view('auth.two-factor-setup', [
|
||||
'secret' => $secret,
|
||||
'qrSvg' => $qrSvg,
|
||||
]);
|
||||
}
|
||||
|
||||
public function enable(Request $request, TwoFactorService $twoFactor): RedirectResponse
|
||||
{
|
||||
$request->validate(['code' => ['required', 'string', 'size:6']]);
|
||||
$secret = $request->session()->pull('two_factor_setup_secret');
|
||||
if (! $secret || ! $twoFactor->enable($request->user(), $secret, $request->input('code'))) {
|
||||
return back()->withErrors(['code' => 'Ungültiger Code. Bitte erneut versuchen.']);
|
||||
}
|
||||
|
||||
$request->session()->put('two_factor_passed', true);
|
||||
|
||||
return redirect()->route('dashboard')->with('success', 'Zwei-Faktor-Authentifizierung aktiviert.');
|
||||
}
|
||||
|
||||
public function challenge(): View
|
||||
{
|
||||
return view('auth.two-factor-challenge');
|
||||
}
|
||||
|
||||
public function verifyChallenge(Request $request, TwoFactorService $twoFactor): RedirectResponse
|
||||
{
|
||||
$request->validate(['code' => ['required', 'string']]);
|
||||
|
||||
if (! $twoFactor->verify($request->user(), $request->input('code'))) {
|
||||
return back()->withErrors(['code' => 'Ungültiger Authentifizierungscode.']);
|
||||
}
|
||||
|
||||
$request->session()->put('two_factor_passed', true);
|
||||
|
||||
return redirect()->intended(route('dashboard'));
|
||||
}
|
||||
}
|
||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
58
app/Http/Controllers/CustomerProvisioningController.php
Normal file
58
app/Http/Controllers/CustomerProvisioningController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreCustomerRequest;
|
||||
use App\Jobs\ProvisionCustomerJob;
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CustomerProvisioningController extends Controller
|
||||
{
|
||||
public function store(StoreCustomerRequest $request): JsonResponse
|
||||
{
|
||||
$domain = $request->domain();
|
||||
|
||||
$customer = Customer::query()->create([
|
||||
'name' => $request->validated('name'),
|
||||
'domain' => $domain,
|
||||
'cpu' => $request->integer('cpu', config('hosting.defaults.cpu')),
|
||||
'ram' => $request->integer('ram', config('hosting.defaults.ram')),
|
||||
'disk' => $request->integer('disk', config('hosting.defaults.disk')),
|
||||
'status' => 'pending',
|
||||
'provisioning_step' => 'queued',
|
||||
]);
|
||||
|
||||
ProvisionCustomerJob::dispatch($customer->id);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Provisioning started.',
|
||||
'customer' => $customer,
|
||||
], 202);
|
||||
}
|
||||
|
||||
public function show(Customer $customer): JsonResponse
|
||||
{
|
||||
return response()->json(['customer' => $customer]);
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'customers' => Customer::query()->latest()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Customer $customer): JsonResponse
|
||||
{
|
||||
if ($customer->status === 'pending' && $customer->provisioning_step === 'queued') {
|
||||
$customer->delete();
|
||||
|
||||
return response()->json(['message' => 'Queued customer removed.']);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Use deprovision endpoint for active customers.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
17
app/Http/Controllers/Web/Admin/SystemHealthController.php
Normal file
17
app/Http/Controllers/Web/Admin/SystemHealthController.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Hosting\Health\SystemHealthService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SystemHealthController extends Controller
|
||||
{
|
||||
public function __invoke(SystemHealthService $health): View
|
||||
{
|
||||
return view('admin.health', [
|
||||
'checks' => $health->checks(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/Web/Admin/VmTemplateController.php
Normal file
72
app/Http/Controllers/Web/Admin/VmTemplateController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\VmTemplate;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VmTemplateController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$templates = VmTemplate::query()->orderBy('name')->get();
|
||||
|
||||
return view('admin.templates.index', compact('templates'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.templates.create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug'],
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
|
||||
'os_family' => ['nullable', 'string', 'max:32'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
VmTemplate::query()->create([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.templates.index')->with('success', 'Template angelegt.');
|
||||
}
|
||||
|
||||
public function edit(VmTemplate $template): View
|
||||
{
|
||||
return view('admin.templates.edit', compact('template'));
|
||||
}
|
||||
|
||||
public function update(Request $request, VmTemplate $template): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug,'.$template->id],
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
|
||||
'os_family' => ['nullable', 'string', 'max:32'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$template->update([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.templates.index')->with('success', 'Template aktualisiert.');
|
||||
}
|
||||
|
||||
public function destroy(VmTemplate $template): RedirectResponse
|
||||
{
|
||||
$template->delete();
|
||||
|
||||
return redirect()->route('admin.templates.index')->with('success', 'Template gelöscht.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Web/DashboardController.php
Normal file
45
app/Http/Controllers/Web/DashboardController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$vmQuery = Customer::query()->forUser($user);
|
||||
|
||||
$stats = [
|
||||
'vms_total' => (clone $vmQuery)->count(),
|
||||
'vms_active' => (clone $vmQuery)->where('status', 'active')->count(),
|
||||
'vms_pending' => (clone $vmQuery)->where('status', 'pending')->count(),
|
||||
'vms_failed' => (clone $vmQuery)->where('status', 'failed')->count(),
|
||||
];
|
||||
|
||||
$pools = IpPool::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('type')
|
||||
->get();
|
||||
|
||||
$recentVms = Customer::query()
|
||||
->forUser($user)
|
||||
->with(['owner', 'ipPool'])
|
||||
->latest()
|
||||
->limit(8)
|
||||
->get();
|
||||
|
||||
return view('dashboard', [
|
||||
'stats' => $stats,
|
||||
'pools' => $pools,
|
||||
'recentVms' => $recentVms,
|
||||
'usersCount' => $user->isAdmin() ? User::query()->count() : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/Web/IpPoolController.php
Normal file
123
app/Http/Controllers/Web/IpPoolController.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\IpPoolType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class IpPoolController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', IpPool::class);
|
||||
|
||||
$pools = IpPool::query()
|
||||
->withCount(['vms' => fn ($q) => $q->whereIn('status', ['pending', 'active'])])
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (IpPool $pool) {
|
||||
$pool->setAttribute('usage_percent', $pool->totalIps() > 0
|
||||
? round(($pool->usedIpsCount() / $pool->totalIps()) * 100, 1)
|
||||
: 0);
|
||||
|
||||
return $pool;
|
||||
});
|
||||
|
||||
$assignments = Customer::query()
|
||||
->forUser($request->user())
|
||||
->with(['owner', 'ipPool'])
|
||||
->whereIn('status', ['pending', 'active'])
|
||||
->where(fn ($q) => $q->whereNotNull('ip_address')->orWhereNotNull('public_ip'))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('ip-pools.index', compact('pools', 'assignments'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', IpPool::class);
|
||||
|
||||
return view('ip-pools.create', ['types' => IpPoolType::cases()]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', IpPool::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'type' => ['required', Rule::enum(IpPoolType::class)],
|
||||
'start_ip' => ['required', 'ip'],
|
||||
'end_ip' => ['required', 'ip'],
|
||||
'gateway' => ['nullable', 'ip'],
|
||||
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
if (ip2long($data['start_ip']) > ip2long($data['end_ip'])) {
|
||||
return back()->withErrors(['end_ip' => 'End-IP muss größer als Start-IP sein.'])->withInput();
|
||||
}
|
||||
|
||||
IpPool::query()->create([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool erstellt.');
|
||||
}
|
||||
|
||||
public function edit(IpPool $ipPool): View
|
||||
{
|
||||
$this->authorize('update', $ipPool);
|
||||
|
||||
return view('ip-pools.edit', [
|
||||
'pool' => $ipPool,
|
||||
'types' => IpPoolType::cases(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, IpPool $ipPool): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $ipPool);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'type' => ['required', Rule::enum(IpPoolType::class)],
|
||||
'start_ip' => ['required', 'ip'],
|
||||
'end_ip' => ['required', 'ip'],
|
||||
'gateway' => ['nullable', 'ip'],
|
||||
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$ipPool->update([
|
||||
...$data,
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool aktualisiert.');
|
||||
}
|
||||
|
||||
public function destroy(IpPool $ipPool): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $ipPool);
|
||||
|
||||
if ($ipPool->vms()->whereIn('status', ['pending', 'active'])->exists()) {
|
||||
return back()->withErrors(['pool' => 'Pool wird noch von aktiven VMs verwendet.']);
|
||||
}
|
||||
|
||||
$ipPool->delete();
|
||||
|
||||
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool gelöscht.');
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Web/IsoUploadController.php
Normal file
38
app/Http/Controllers/Web/IsoUploadController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerIsoUpload;
|
||||
use App\Services\Hosting\Iso\IsoUploadService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class IsoUploadController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$uploads = CustomerIsoUpload::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('iso-uploads.index', compact('uploads'));
|
||||
}
|
||||
|
||||
public function store(Request $request, IsoUploadService $service): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'iso' => ['required', 'file', 'mimes:iso', 'max:'.((int) config('hosting.iso_upload.max_size_mb', 10240) * 1024)],
|
||||
]);
|
||||
|
||||
try {
|
||||
$service->upload($request->user(), $request->file('iso'));
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return redirect()->route('iso-uploads.index')->with('success', 'ISO hochgeladen.');
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Web/UserController.php
Normal file
106
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$users = User::query()->withCount('vms')->orderBy('name')->paginate(20);
|
||||
|
||||
return view('users.index', compact('users'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
return view('users.create', ['roles' => UserRole::cases()]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'role' => ['required', Rule::enum(UserRole::class)],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
User::query()->create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
'role' => $data['role'],
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Benutzer erstellt.');
|
||||
}
|
||||
|
||||
public function edit(User $user): View
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
return view('users.edit', [
|
||||
'user' => $user,
|
||||
'roles' => UserRole::cases(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||
'role' => ['required', Rule::enum(UserRole::class)],
|
||||
'is_active' => ['boolean'],
|
||||
]);
|
||||
|
||||
$user->fill([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'role' => $data['role'],
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
]);
|
||||
|
||||
if (! empty($data['password'])) {
|
||||
$user->password = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Benutzer aktualisiert.');
|
||||
}
|
||||
|
||||
public function destroy(User $user): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $user);
|
||||
|
||||
if ($user->vms()->whereIn('status', ['pending', 'active'])->exists()) {
|
||||
return back()->withErrors(['user' => 'Benutzer hat noch aktive VMs.']);
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('users.index')->with('success', 'Benutzer gelöscht.');
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Web/VmBackupController.php
Normal file
24
app/Http/Controllers/Web/VmBackupController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Backups\BackupService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VmBackupController extends Controller
|
||||
{
|
||||
public function store(Customer $vm, BackupService $backups): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
try {
|
||||
$backups->start($vm, auth()->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['backup' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Backup gestartet.');
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/VmConsoleController.php
Normal file
47
app/Http/Controllers/Web/VmConsoleController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VmConsoleController extends Controller
|
||||
{
|
||||
public function create(Request $request, Customer $vm, VmManagementService $management): View
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
try {
|
||||
$session = $management->createConsoleSession($vm, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return view('vms.console-error', [
|
||||
'vm' => $vm,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('vms.console', [
|
||||
'vm' => $vm,
|
||||
'token' => $session['token'],
|
||||
'wsUrl' => $session['ws_url'],
|
||||
'expiresAt' => $session['expires_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $token, VmManagementService $management): View
|
||||
{
|
||||
$session = $management->getConsoleSession($token, $request->user());
|
||||
$vm = Customer::query()->findOrFail($session['customer_id']);
|
||||
$this->authorize('view', $vm);
|
||||
|
||||
return view('vms.console', [
|
||||
'vm' => $vm,
|
||||
'token' => $token,
|
||||
'wsUrl' => $session['ws_url'],
|
||||
'expiresAt' => now()->addMinutes(5)->timestamp,
|
||||
]);
|
||||
}
|
||||
}
|
||||
215
app/Http/Controllers/Web/VmController.php
Normal file
215
app/Http/Controllers/Web/VmController.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreVmRequest;
|
||||
use App\Http\Requests\UpdateVmRequest;
|
||||
use App\Jobs\ProvisionCustomerJob;
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use App\Models\User;
|
||||
use App\Models\VmDevice;
|
||||
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||
use App\Services\Hosting\Provisioning\DeprovisionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class VmController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('viewAny', Customer::class);
|
||||
|
||||
$vms = Customer::query()
|
||||
->forUser($request->user())
|
||||
->with(['owner', 'ipPool'])
|
||||
->when($request->query('status'), fn ($q, $status) => $q->where('status', $status))
|
||||
->latest()
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
return view('vms.index', compact('vms'));
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$this->authorize('create', Customer::class);
|
||||
|
||||
$isos = [];
|
||||
try {
|
||||
$isos = app(ProxmoxClient::class)->listIsos();
|
||||
} catch (\Throwable) {
|
||||
// Proxmox nicht erreichbar – Formular bleibt nutzbar
|
||||
}
|
||||
|
||||
return view('vms.create', [
|
||||
'privatePools' => IpPool::query()->where('type', 'private')->where('is_active', true)->get(),
|
||||
'customers' => $request->user()->isAdmin()
|
||||
? User::query()->where('role', 'customer')->orderBy('name')->get()
|
||||
: collect(),
|
||||
'deviceTypes' => VmDevice::typesFor($request->user()),
|
||||
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
'isos' => $isos,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreVmRequest $request): RedirectResponse
|
||||
{
|
||||
$domain = $request->domain();
|
||||
|
||||
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
|
||||
return back()->withErrors(['subdomain' => 'Diese Subdomain ist bereits vergeben.'])->withInput();
|
||||
}
|
||||
|
||||
if (! $domain) {
|
||||
$domain = 'direct-'.Str::slug($request->validated('name')).'-'.Str::lower(Str::random(6)).'.internal';
|
||||
}
|
||||
|
||||
$vm = Customer::query()->create([
|
||||
'user_id' => $request->ownerId(),
|
||||
'name' => $request->validated('name'),
|
||||
'domain' => $domain,
|
||||
'behind_traefik' => $request->boolean('behind_traefik'),
|
||||
'ip_pool_id' => $request->input('ip_pool_id'),
|
||||
'cpu' => $request->integer('cpu'),
|
||||
'ram' => $request->integer('ram'),
|
||||
'disk' => $request->integer('disk'),
|
||||
'attached_iso' => $request->validated('install_iso'),
|
||||
'status' => 'pending',
|
||||
'provisioning_step' => 'queued',
|
||||
]);
|
||||
|
||||
$this->syncDevices($vm, $request->input('devices', []));
|
||||
|
||||
ProvisionCustomerJob::dispatch($vm->id);
|
||||
|
||||
return redirect()
|
||||
->route('vms.show', $vm)
|
||||
->with('success', 'VM-Provisioning wurde gestartet.');
|
||||
}
|
||||
|
||||
public function show(Request $request, Customer $vm): View
|
||||
{
|
||||
$this->authorize('view', $vm);
|
||||
|
||||
$vm->load([
|
||||
'owner', 'ipPool', 'devices',
|
||||
'snapshots' => fn ($q) => $q->latest(),
|
||||
'backups' => fn ($q) => $q->latest()->limit(10),
|
||||
'firewallRules',
|
||||
'metrics' => fn ($q) => $q->limit(48),
|
||||
'activityLogs' => fn ($q) => $q->limit(15)->with('user'),
|
||||
]);
|
||||
|
||||
$isos = [];
|
||||
$liveStatus = null;
|
||||
|
||||
if ($vm->vmid && $vm->status === 'active') {
|
||||
try {
|
||||
$proxmox = app(ProxmoxClient::class);
|
||||
$isos = $proxmox->listIsos();
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
|
||||
try {
|
||||
$proxmox ??= app(ProxmoxClient::class);
|
||||
$liveStatus = $proxmox->normalizeLiveStatus($proxmox->getVMStatus((int) $vm->vmid));
|
||||
$vm->update([
|
||||
'proxmox_status' => $liveStatus['status'],
|
||||
'proxmox_uptime' => $liveStatus['uptime'],
|
||||
'proxmox_status_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
return view('vms.show', compact('vm', 'isos', 'liveStatus'));
|
||||
}
|
||||
|
||||
public function edit(Request $request, Customer $vm): View
|
||||
{
|
||||
$this->authorize('update', $vm);
|
||||
|
||||
$vm->load('devices');
|
||||
|
||||
return view('vms.edit', [
|
||||
'vm' => $vm,
|
||||
'deviceTypes' => VmDevice::typesFor($request->user()),
|
||||
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateVmRequest $request, Customer $vm): RedirectResponse
|
||||
{
|
||||
$vm->update($request->only(['name', 'cpu', 'ram', 'disk']));
|
||||
|
||||
if ($request->has('devices')) {
|
||||
$vm->devices()->delete();
|
||||
$this->syncDevices($vm, $request->input('devices', []));
|
||||
}
|
||||
|
||||
if ($vm->vmid && $vm->status === 'active') {
|
||||
try {
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
|
||||
->updateVmResources((int) $vm->vmid, $vm->cpu, $vm->ram, $vm->disk);
|
||||
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
|
||||
->applyDevices((int) $vm->vmid, $vm->devices()->get());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('warning', 'DB gespeichert, Proxmox-Update fehlgeschlagen: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('vms.show', $vm)->with('success', 'VM-Konfiguration gespeichert.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Customer $vm, DeprovisionService $deprovision, \App\Services\Hosting\Snapshots\SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $vm);
|
||||
|
||||
if ($vm->vmid && $vm->status === 'active') {
|
||||
try {
|
||||
$snapshots->autoBeforeDestructive($vm, $request->user(), 'delete');
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
if ($vm->status === 'pending' && $vm->provisioning_step === 'queued') {
|
||||
if ($vm->vmid) {
|
||||
app(\App\Services\Hosting\Provisioning\VmidReservationService::class)
|
||||
->scheduleRelease((int) $vm->vmid, $vm);
|
||||
}
|
||||
$vm->devices()->delete();
|
||||
$vm->delete();
|
||||
|
||||
return redirect()->route('vms.index')->with('success', 'VM-Eintrag gelöscht.');
|
||||
}
|
||||
|
||||
$deprovision->removeVmOnly($vm, $request->user());
|
||||
|
||||
return redirect()->route('vms.index')->with('success', 'VM wurde entfernt. VMID wird nach 48h freigegeben.');
|
||||
}
|
||||
|
||||
private function syncDevices(Customer $vm, array $devices): void
|
||||
{
|
||||
$allowed = array_keys(VmDevice::typesFor(auth()->user()));
|
||||
|
||||
foreach ($devices as $index => $device) {
|
||||
if (empty($device['type']) || ! in_array($device['type'], $allowed, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$vm->devices()->create([
|
||||
'type' => $device['type'],
|
||||
'slot' => $device['slot'] ?? null,
|
||||
'config' => $device['config'] ?? [],
|
||||
'sort_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Web/VmFirewallController.php
Normal file
53
app/Http/Controllers/Web/VmFirewallController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Firewall\FirewallService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class VmFirewallController extends Controller
|
||||
{
|
||||
public function store(Request $request, Customer $vm, FirewallService $firewall): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
$data = $request->validate([
|
||||
'direction' => ['required', Rule::in(['in', 'out'])],
|
||||
'action' => ['required', Rule::in(['ACCEPT', 'DROP', 'REJECT'])],
|
||||
'protocol' => ['required', Rule::in(['tcp', 'udp', 'icmp'])],
|
||||
'port' => ['nullable', 'string', 'max:32'],
|
||||
'source' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
$vm->firewallRules()->create([
|
||||
...$data,
|
||||
'sort_order' => $vm->firewallRules()->count(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$firewall->syncToProxmox($vm);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('warning', 'Regel gespeichert, Proxmox-Sync fehlgeschlagen: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return back()->with('success', 'Firewall-Regel hinzugefügt.');
|
||||
}
|
||||
|
||||
public function destroy(Customer $vm, int $rule, FirewallService $firewall): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
$vm->firewallRules()->whereKey($rule)->delete();
|
||||
|
||||
try {
|
||||
$firewall->syncToProxmox($vm);
|
||||
} catch (\Throwable) {
|
||||
//
|
||||
}
|
||||
|
||||
return back()->with('success', 'Regel entfernt.');
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Web/VmIsoController.php
Normal file
55
app/Http/Controllers/Web/VmIsoController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmIsoController extends Controller
|
||||
{
|
||||
public function store(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
$data = $request->validate([
|
||||
'iso_volid' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$snapshots->autoBeforeDestructive($vm, $request->user(), 'iso-mount');
|
||||
|
||||
try {
|
||||
$management->mountIso($vm, $data['iso_volid'], $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'ISO wurde eingebunden. VM ggf. neu starten, um vom Installationsmedium zu booten.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Customer $vm, VmManagementService $management): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
try {
|
||||
$management->unmountIso($vm, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'ISO wurde entfernt.');
|
||||
}
|
||||
|
||||
public function index(ProxmoxClient $proxmox): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json(['isos' => $proxmox->listIsos()]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/Http/Controllers/Web/VmPowerController.php
Normal file
36
app/Http/Controllers/Web/VmPowerController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\VmPowerAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class VmPowerController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
|
||||
$action = VmPowerAction::from($request->validate([
|
||||
'action' => ['required', Rule::enum(VmPowerAction::class)],
|
||||
])['action']);
|
||||
|
||||
if (in_array($action, [VmPowerAction::Stop, VmPowerAction::Reset, VmPowerAction::Reboot], true)) {
|
||||
$snapshots->autoBeforeDestructive($vm, $request->user(), 'power-'.$action->value);
|
||||
}
|
||||
|
||||
try {
|
||||
$management->power($vm, $action, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['power' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', $action->label().' wurde ausgeführt.');
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Web/VmReinstallController.php
Normal file
26
app/Http/Controllers/Web/VmReinstallController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Reinstall\ReinstallService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmReinstallController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Customer $vm, ReinstallService $reinstall): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
$request->validate(['confirm' => ['required', 'in:REINSTALL']]);
|
||||
|
||||
try {
|
||||
$reinstall->reinstall($vm, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['reinstall' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return redirect()->route('vms.show', $vm)->with('success', 'Neuinstallation gestartet.');
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Web/VmSnapshotController.php
Normal file
55
app/Http/Controllers/Web/VmSnapshotController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\VmSnapshot;
|
||||
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmSnapshotController extends Controller
|
||||
{
|
||||
public function store(Request $request, Customer $vm, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
$request->validate(['label' => ['nullable', 'string', 'max:64']]);
|
||||
|
||||
try {
|
||||
$snapshots->create($vm, $request->user(), false, $request->input('label'));
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Snapshot erstellt.');
|
||||
}
|
||||
|
||||
public function rollback(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
abort_unless($snapshot->customer_id === $vm->id, 404);
|
||||
|
||||
try {
|
||||
$snapshots->rollback($vm, $snapshot);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Snapshot wiederhergestellt.');
|
||||
}
|
||||
|
||||
public function destroy(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
|
||||
{
|
||||
$this->authorize('manage', $vm);
|
||||
abort_unless($snapshot->customer_id === $vm->id, 404);
|
||||
|
||||
try {
|
||||
$snapshots->delete($vm, $snapshot);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Snapshot gelöscht.');
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/Web/VmStatusController.php
Normal file
42
app/Http/Controllers/Web/VmStatusController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VmStatusController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, Customer $vm, VmManagementService $management): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $vm);
|
||||
|
||||
if (! $vm->vmid || $vm->status !== 'active') {
|
||||
return response()->json([
|
||||
'status' => $vm->status,
|
||||
'proxmox' => null,
|
||||
'message' => 'VM nicht bereit',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$live = $management->refreshLiveStatus($vm);
|
||||
|
||||
return response()->json([
|
||||
'status' => $vm->status,
|
||||
'proxmox' => $live,
|
||||
'attached_iso' => $vm->attached_iso,
|
||||
'refreshed_at' => $vm->proxmox_status_at?->toIso8601String(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'status' => $vm->status,
|
||||
'proxmox' => null,
|
||||
'error' => $e->getMessage(),
|
||||
], 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Http/Middleware/EnsureTwoFactorVerified.php
Normal file
33
app/Http/Middleware/EnsureTwoFactorVerified.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Auth\TwoFactorService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureTwoFactorVerified
|
||||
{
|
||||
public function __construct(private readonly TwoFactorService $twoFactor) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($this->twoFactor->mustSetup($user) && ! $request->routeIs('two-factor.*')) {
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
if ($this->twoFactor->isEnabled($user) && ! $request->session()->get('two_factor_passed')) {
|
||||
if (! $request->routeIs('two-factor.challenge', 'two-factor.challenge.store', 'logout')) {
|
||||
return redirect()->route('two-factor.challenge');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
19
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->isAdmin()) {
|
||||
abort(403, 'Zugriff nur für Administratoren.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
48
app/Http/Middleware/VerifyWhmcsRequest.php
Normal file
48
app/Http/Middleware/VerifyWhmcsRequest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class VerifyWhmcsRequest
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! config('hosting.whmcs.enabled', false)) {
|
||||
abort(503, 'WHMCS integration is disabled.');
|
||||
}
|
||||
|
||||
$secret = config('hosting.whmcs.api_secret');
|
||||
if (empty($secret)) {
|
||||
abort(500, 'WHMCS API secret is not configured.');
|
||||
}
|
||||
|
||||
$allowedIps = config('hosting.whmcs.allowed_ips', []);
|
||||
if ($allowedIps !== [] && ! in_array($request->ip(), $allowedIps, true)) {
|
||||
abort(403, 'IP not allowed.');
|
||||
}
|
||||
|
||||
$timestamp = (int) $request->header('X-Whmcs-Timestamp', 0);
|
||||
$signature = (string) $request->header('X-Whmcs-Signature', '');
|
||||
$window = (int) config('hosting.whmcs.replay_window_seconds', 300);
|
||||
|
||||
if ($timestamp === 0 || $signature === '') {
|
||||
abort(401, 'Missing WHMCS signature headers.');
|
||||
}
|
||||
|
||||
if (abs(time() - $timestamp) > $window) {
|
||||
abort(401, 'Request timestamp expired.');
|
||||
}
|
||||
|
||||
$payload = $timestamp.'.'.$request->getContent();
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
if (! hash_equals($expected, $signature)) {
|
||||
abort(401, 'Invalid WHMCS signature.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/StoreCustomerRequest.php
Normal file
42
app/Http/Requests/StoreCustomerRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$baseDomain = config('hosting.plesk.base_domain');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'subdomain' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:63',
|
||||
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||
function (string $attribute, mixed $value, \Closure $fail) use ($baseDomain): void {
|
||||
$domain = strtolower((string) $value).'.'.$baseDomain;
|
||||
if (\App\Models\Customer::query()->where('domain', $domain)->exists()) {
|
||||
$fail("The domain {$domain} is already registered.");
|
||||
}
|
||||
},
|
||||
],
|
||||
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
|
||||
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
|
||||
];
|
||||
}
|
||||
|
||||
public function domain(): string
|
||||
{
|
||||
return strtolower($this->validated('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||
}
|
||||
}
|
||||
64
app/Http/Requests/StoreVmRequest.php
Normal file
64
app/Http/Requests/StoreVmRequest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreVmRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('create', Customer::class);
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$baseDomain = config('hosting.plesk.base_domain');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'subdomain' => [
|
||||
Rule::requiredIf(fn () => $this->boolean('behind_traefik')),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:63',
|
||||
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||
],
|
||||
'behind_traefik' => ['boolean'],
|
||||
'user_id' => [
|
||||
Rule::requiredIf(fn () => $this->user()->isAdmin()),
|
||||
'nullable',
|
||||
'exists:users,id',
|
||||
],
|
||||
'ip_pool_id' => ['nullable', 'exists:ip_pools,id'],
|
||||
'cpu' => ['required', 'integer', 'min:1', 'max:32'],
|
||||
'ram' => ['required', 'integer', 'min:512', 'max:131072'],
|
||||
'disk' => ['required', 'integer', 'min:10', 'max:2048'],
|
||||
'devices' => ['nullable', 'array'],
|
||||
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
|
||||
'devices.*.slot' => ['nullable', 'string', 'max:32'],
|
||||
'devices.*.config' => ['nullable', 'array'],
|
||||
'install_iso' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
if (! $this->boolean('behind_traefik') || ! $this->filled('subdomain')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||
}
|
||||
|
||||
public function ownerId(): int
|
||||
{
|
||||
if ($this->user()->isAdmin() && $this->filled('user_id')) {
|
||||
return (int) $this->input('user_id');
|
||||
}
|
||||
|
||||
return $this->user()->id;
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/UpdateVmRequest.php
Normal file
28
app/Http/Requests/UpdateVmRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateVmRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update', $this->route('vm'));
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
|
||||
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
|
||||
'devices' => ['nullable', 'array'],
|
||||
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
|
||||
'devices.*.slot' => ['nullable', 'string', 'max:32'],
|
||||
'devices.*.config' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
57
app/Http/Requests/Whmcs/ProvisionServiceRequest.php
Normal file
57
app/Http/Requests/Whmcs/ProvisionServiceRequest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Whmcs;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProvisionServiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'whmcs_service_id' => ['required', 'integer', 'unique:whmcs_services,whmcs_service_id'],
|
||||
'whmcs_client_id' => ['required', 'integer'],
|
||||
'whmcs_order_id' => ['nullable', 'integer'],
|
||||
'client_email' => ['required', 'email', 'max:255'],
|
||||
'client_name' => ['required', 'string', 'max:100'],
|
||||
'plan_slug' => ['required', 'string', 'exists:hosting_plans,slug'],
|
||||
'hostname' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||
'subdomain' => [
|
||||
Rule::requiredIf(fn () => $this->boolean('behind_traefik', true)),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:63',
|
||||
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||
],
|
||||
'behind_traefik' => ['boolean'],
|
||||
'provision_mode' => ['required', Rule::in(['template', 'iso', 'empty'])],
|
||||
'template_slug' => [
|
||||
Rule::requiredIf(fn () => $this->input('provision_mode') === 'template'),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:64',
|
||||
],
|
||||
'iso_volid' => [
|
||||
Rule::requiredIf(fn () => $this->input('provision_mode') === 'iso'),
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
if (! $this->boolean('behind_traefik', true) || ! $this->filled('subdomain')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user