initial commit
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{compose,docker-compose}.{yml,yaml}]
|
||||||
|
indent_size = 4
|
||||||
138
.env.example
Normal file
138
.env.example
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
APP_NAME="HexaHost Panel"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=https://panel.hexahost.de
|
||||||
|
|
||||||
|
APP_LOCALE=de
|
||||||
|
APP_FALLBACK_LOCALE=de
|
||||||
|
APP_FAKER_LOCALE=de_DE
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# --- Datenbank (Produktion: mariadb/mysql auf Plesk) ---
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
# DB_CONNECTION=mariadb
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_DATABASE=hexahost_panel
|
||||||
|
# DB_USERNAME=panel_user
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="panel@hexahost.de"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# --- Proxmox VE ---
|
||||||
|
PROXMOX_URL=https://hyperion.rz-wob.hexahost.de:8006
|
||||||
|
PROXMOX_CONSOLE_WS_URL=https://hyperion.rz-wob.hexahost.de:8006
|
||||||
|
PROXMOX_TOKEN=user@pam!tokenid=secret
|
||||||
|
PROXMOX_NODE=hyperion
|
||||||
|
PROXMOX_STORAGE=vmdata
|
||||||
|
PROXMOX_BRIDGE=vmbr0
|
||||||
|
PROXMOX_PUBLIC_BRIDGE=vmbr1
|
||||||
|
PROXMOX_ISO_STORAGE=ISO
|
||||||
|
PROXMOX_ISO_DEVICE=ide2
|
||||||
|
PROXMOX_BACKUP_STORAGE=inett-PBS
|
||||||
|
PROXMOX_TEMPLATE_VMID=
|
||||||
|
PROXMOX_TIMEOUT=120
|
||||||
|
PROXMOX_VERIFY_SSL=true
|
||||||
|
|
||||||
|
# --- Privates VM-Netz 10.32.0.0/24 ---
|
||||||
|
HOSTING_GATEWAY=10.32.0.1
|
||||||
|
HOSTING_NETWORK_CIDR=24
|
||||||
|
HOSTING_IP_POOL_START=10.32.0.10
|
||||||
|
HOSTING_IP_POOL_END=10.32.0.254
|
||||||
|
|
||||||
|
# --- Öffentliche IPs (ohne Traefik) ---
|
||||||
|
HOSTING_PUBLIC_POOL_START=185.45.149.246
|
||||||
|
HOSTING_PUBLIC_POOL_END=185.45.149.252
|
||||||
|
HOSTING_PUBLIC_GATEWAY=185.45.149.241
|
||||||
|
HOSTING_PUBLIC_CIDR=28
|
||||||
|
|
||||||
|
# --- VMID-Pool ---
|
||||||
|
VMID_RANGE_START=2000
|
||||||
|
VMID_RANGE_END=2999
|
||||||
|
VMID_RELEASE_AFTER_HOURS=48
|
||||||
|
|
||||||
|
# --- Snapshots / Backups / ISO ---
|
||||||
|
SNAPSHOT_RETENTION_HOURS=48
|
||||||
|
SNAPSHOT_AUTO_BEFORE_DESTRUCTIVE=true
|
||||||
|
MAX_BACKUPS_PER_CUSTOMER=4
|
||||||
|
BACKUPS_ENABLED=false
|
||||||
|
|
||||||
|
ISO_UPLOAD_ENABLED=true
|
||||||
|
ISO_UPLOAD_MAX_PER_CUSTOMER=1
|
||||||
|
ISO_UPLOAD_MAX_SIZE_MB=10240
|
||||||
|
ISO_UPLOAD_RETENTION_HOURS=48
|
||||||
|
|
||||||
|
# --- WHMCS API ---
|
||||||
|
WHMCS_ENABLED=true
|
||||||
|
WHMCS_API_SECRET=
|
||||||
|
WHMCS_ALLOWED_IPS=
|
||||||
|
WHMCS_REPLAY_WINDOW=300
|
||||||
|
|
||||||
|
# --- Plesk DNS ---
|
||||||
|
PLESK_URL=https://plesk.example.com:8443
|
||||||
|
PLESK_USER=admin
|
||||||
|
PLESK_PASS=
|
||||||
|
PLESK_BASE_DOMAIN=hexahost.de
|
||||||
|
PLESK_TIMEOUT=30
|
||||||
|
PLESK_VERIFY_SSL=true
|
||||||
|
PLESK_MAIL_ENABLED=true
|
||||||
|
|
||||||
|
# --- Traefik (File Provider) ---
|
||||||
|
TRAEFIK_DYNAMIC_CONFIG_PATH=/etc/traefik/dynamic/customers.yaml
|
||||||
|
TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
TRAEFIK_CERT_RESOLVER=letsencrypt
|
||||||
|
TRAEFIK_BACKEND_PORT=80
|
||||||
|
TRAEFIK_PUBLIC_IP=185.45.149.98
|
||||||
|
TRAEFIK_RELOAD_COMMAND="docker exec traefik kill -HUP 1"
|
||||||
|
|
||||||
|
# --- Sicherheit / Konsole ---
|
||||||
|
ADMIN_2FA_REQUIRED=true
|
||||||
|
LOGIN_MAX_ATTEMPTS=5
|
||||||
|
VM_POWER_RATE_LIMIT=20
|
||||||
|
|
||||||
|
CONSOLE_PROXY_ENABLED=false
|
||||||
|
CONSOLE_PROXY_WS_URL=wss://panel.hexahost.de/ws/vm
|
||||||
|
CONSOLE_PROXY_SECRET=
|
||||||
|
CONSOLE_PROXY_VALIDATE_URL=https://panel.hexahost.de/api/console/validate
|
||||||
|
|
||||||
|
# --- Benachrichtigungen ---
|
||||||
|
HOSTING_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# --- Standard-Ressourcen neuer VMs ---
|
||||||
|
HOSTING_DEFAULT_CPU=2
|
||||||
|
HOSTING_DEFAULT_RAM=2048
|
||||||
|
HOSTING_DEFAULT_DISK=32
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.codex
|
||||||
|
/.cursor/
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/fonts-manifest.dev.json
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
_ide_helper.php
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## About Laravel
|
||||||
|
|
||||||
|
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||||
|
|
||||||
|
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||||
|
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||||
|
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||||
|
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||||
|
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||||
|
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||||
|
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||||
|
|
||||||
|
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||||
|
|
||||||
|
## Learning Laravel
|
||||||
|
|
||||||
|
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||||
|
|
||||||
|
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||||
|
|
||||||
|
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||||
|
|
||||||
|
## Agentic Development
|
||||||
|
|
||||||
|
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require laravel/boost --dev
|
||||||
|
|
||||||
|
php artisan boost:install
|
||||||
|
```
|
||||||
|
|
||||||
|
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||||
|
|
||||||
|
## Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
21
app/Console/Commands/CollectVmMetricsCommand.php
Normal file
21
app/Console/Commands/CollectVmMetricsCommand.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Hosting\Metrics\MetricsCollectorService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CollectVmMetricsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'hosting:collect-metrics';
|
||||||
|
|
||||||
|
protected $description = 'Sammelt VM-Metriken von Proxmox';
|
||||||
|
|
||||||
|
public function handle(MetricsCollectorService $collector): int
|
||||||
|
{
|
||||||
|
$count = $collector->collectAll();
|
||||||
|
$this->info("Metriken für {$count} VM(s) erfasst.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Console/Commands/PruneSnapshotsCommand.php
Normal file
21
app/Console/Commands/PruneSnapshotsCommand.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class PruneSnapshotsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'hosting:prune-snapshots';
|
||||||
|
|
||||||
|
protected $description = 'Entfernt abgelaufene VM-Snapshots';
|
||||||
|
|
||||||
|
public function handle(SnapshotService $snapshots): int
|
||||||
|
{
|
||||||
|
$count = $snapshots->pruneExpired();
|
||||||
|
$this->info("{$count} Snapshot(s) bereinigt.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Console/Commands/PurgeExpiredIsoUploadsCommand.php
Normal file
35
app/Console/Commands/PurgeExpiredIsoUploadsCommand.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\CustomerIsoUpload;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PurgeExpiredIsoUploadsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'hosting:purge-iso-uploads';
|
||||||
|
|
||||||
|
protected $description = 'Remove expired customer ISO uploads from Proxmox storage';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$expired = CustomerIsoUpload::query()
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($expired as $upload) {
|
||||||
|
try {
|
||||||
|
// Proxmox delete via storage API when upload service is wired
|
||||||
|
Log::info('ISO upload expired', ['volid' => $upload->volid, 'user_id' => $upload->user_id]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('ISO purge failed', ['id' => $upload->id, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
$upload->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Purged {$expired->count()} expired ISO upload(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Console/Commands/RebuildTraefikRoutesCommand.php
Normal file
32
app/Console/Commands/RebuildTraefikRoutesCommand.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Traefik\TraefikGenerator;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class RebuildTraefikRoutesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'hosting:traefik-rebuild {--reload : Reload Traefik after rebuild}';
|
||||||
|
|
||||||
|
protected $description = 'Rebuild all customer Traefik routes from database';
|
||||||
|
|
||||||
|
public function handle(TraefikGenerator $traefik): int
|
||||||
|
{
|
||||||
|
$customers = Customer::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->whereNotNull('ip_address')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$traefik->rebuildAllRoutes($customers);
|
||||||
|
|
||||||
|
if ($this->option('reload')) {
|
||||||
|
$traefik->reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Rebuilt routes for {$customers->count()} active customers.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Console/Commands/ReleaseVmidReservationsCommand.php
Normal file
21
app/Console/Commands/ReleaseVmidReservationsCommand.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Hosting\Provisioning\VmidReservationService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ReleaseVmidReservationsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'hosting:release-vmids';
|
||||||
|
|
||||||
|
protected $description = 'Release VMIDs after retention period (default 48h)';
|
||||||
|
|
||||||
|
public function handle(VmidReservationService $service): int
|
||||||
|
{
|
||||||
|
$count = $service->releaseDue();
|
||||||
|
$this->info("Released {$count} VMID reservation(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Enums/IpPoolType.php
Normal file
17
app/Enums/IpPoolType.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum IpPoolType: string
|
||||||
|
{
|
||||||
|
case Private = 'private';
|
||||||
|
case Public = 'public';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Private => 'Privat (intern)',
|
||||||
|
self::Public => 'Öffentlich',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Enums/UserRole.php
Normal file
17
app/Enums/UserRole.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum UserRole: string
|
||||||
|
{
|
||||||
|
case Admin = 'admin';
|
||||||
|
case Customer = 'customer';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Admin => 'Administrator',
|
||||||
|
self::Customer => 'Kunde',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Enums/VmPowerAction.php
Normal file
33
app/Enums/VmPowerAction.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum VmPowerAction: string
|
||||||
|
{
|
||||||
|
case Start = 'start';
|
||||||
|
case Shutdown = 'shutdown';
|
||||||
|
case Stop = 'stop';
|
||||||
|
case Reboot = 'reboot';
|
||||||
|
case Reset = 'reset';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Start => 'Starten',
|
||||||
|
self::Shutdown => 'Herunterfahren (ACPI)',
|
||||||
|
self::Stop => 'Stoppen (Force)',
|
||||||
|
self::Reboot => 'Neustart',
|
||||||
|
self::Reset => 'Reset (Hard)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresRunning(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::Shutdown, self::Stop, self::Reboot, self::Reset], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresStopped(): bool
|
||||||
|
{
|
||||||
|
return $this === self::Start;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Exceptions/Hosting/PleskException.php
Normal file
7
app/Exceptions/Hosting/PleskException.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\Hosting;
|
||||||
|
|
||||||
|
class PleskException extends ProvisioningException
|
||||||
|
{
|
||||||
|
}
|
||||||
18
app/Exceptions/Hosting/ProvisioningException.php
Normal file
18
app/Exceptions/Hosting/ProvisioningException.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\Hosting;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ProvisioningException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
public readonly ?string $step = null,
|
||||||
|
public readonly ?array $context = null,
|
||||||
|
int $code = 0,
|
||||||
|
?\Throwable $previous = null,
|
||||||
|
) {
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Exceptions/Hosting/ProxmoxException.php
Normal file
7
app/Exceptions/Hosting/ProxmoxException.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\Hosting;
|
||||||
|
|
||||||
|
class ProxmoxException extends ProvisioningException
|
||||||
|
{
|
||||||
|
}
|
||||||
7
app/Exceptions/Hosting/TraefikException.php
Normal file
7
app/Exceptions/Hosting/TraefikException.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions\Hosting;
|
||||||
|
|
||||||
|
class TraefikException extends ProvisioningException
|
||||||
|
{
|
||||||
|
}
|
||||||
30
app/Http/Controllers/Api/ConsoleValidateController.php
Normal file
30
app/Http/Controllers/Api/ConsoleValidateController.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ConsoleValidateController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, string $token, VmManagementService $management): JsonResponse
|
||||||
|
{
|
||||||
|
$secret = config('hosting.console.proxy_secret');
|
||||||
|
if (! $secret || $request->header('X-Console-Proxy-Secret') !== $secret) {
|
||||||
|
return response()->json(['error' => 'forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = \Illuminate\Support\Facades\Cache::get($management->consoleCacheKey($token));
|
||||||
|
if (! $data) {
|
||||||
|
return response()->json(['error' => 'expired'], 410);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'proxmox_ws_url' => $data['ws_url'],
|
||||||
|
'vmid' => $data['vmid'],
|
||||||
|
'node' => $data['node'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Http/Controllers/Api/WhmcsServiceController.php
Normal file
162
app/Http/Controllers/Api/WhmcsServiceController.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Whmcs\ProvisionServiceRequest;
|
||||||
|
use App\Jobs\ProvisionCustomerJob;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\HostingPlan;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WhmcsService;
|
||||||
|
use App\Services\Hosting\Provisioning\DeprovisionService;
|
||||||
|
use App\Services\Hosting\Provisioning\VmidReservationService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class WhmcsServiceController extends Controller
|
||||||
|
{
|
||||||
|
public function provision(ProvisionServiceRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$plan = HostingPlan::query()->where('slug', $request->validated('plan_slug'))->firstOrFail();
|
||||||
|
$domain = $request->domain();
|
||||||
|
|
||||||
|
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
|
||||||
|
return response()->json(['error' => 'Domain already taken.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = DB::transaction(function () use ($request, $plan, $domain) {
|
||||||
|
$user = User::query()->updateOrCreate(
|
||||||
|
['whmcs_client_id' => $request->integer('whmcs_client_id')],
|
||||||
|
[
|
||||||
|
'name' => $request->validated('client_name'),
|
||||||
|
'email' => $request->validated('client_email'),
|
||||||
|
'password' => Hash::make(Str::random(32)),
|
||||||
|
'role' => UserRole::Customer,
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$customer = Customer::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'hosting_plan_id' => $plan->id,
|
||||||
|
'name' => $request->validated('hostname'),
|
||||||
|
'domain' => $domain ?? 'direct-'.Str::slug($request->validated('hostname')).'-'.Str::lower(Str::random(6)).'.internal',
|
||||||
|
'behind_traefik' => $request->boolean('behind_traefik', true),
|
||||||
|
'provision_mode' => $request->validated('provision_mode'),
|
||||||
|
'attached_iso' => $request->validated('iso_volid'),
|
||||||
|
'cpu' => $plan->cpu,
|
||||||
|
'ram' => $plan->ram,
|
||||||
|
'disk' => $plan->disk,
|
||||||
|
'status' => 'pending',
|
||||||
|
'provisioning_step' => 'queued',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$whmcsService = WhmcsService::query()->create([
|
||||||
|
'whmcs_service_id' => $request->integer('whmcs_service_id'),
|
||||||
|
'whmcs_client_id' => $request->integer('whmcs_client_id'),
|
||||||
|
'whmcs_order_id' => $request->input('whmcs_order_id'),
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'hosting_plan_id' => $plan->id,
|
||||||
|
'status' => 'provisioning',
|
||||||
|
'config' => $request->only([
|
||||||
|
'provision_mode', 'template_slug', 'iso_volid', 'behind_traefik', 'subdomain',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$customer->update(['whmcs_service_id' => $whmcsService->id]);
|
||||||
|
|
||||||
|
$vmid = app(VmidReservationService::class)->reserveForCustomer($customer);
|
||||||
|
$customer->update(['vmid' => $vmid]);
|
||||||
|
|
||||||
|
ProvisionCustomerJob::dispatch($customer->id);
|
||||||
|
|
||||||
|
return compact('customer', 'user', 'whmcsService', 'vmid');
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Provisioning queued.',
|
||||||
|
'customer_id' => $result['customer']->id,
|
||||||
|
'vmid' => $result['vmid'],
|
||||||
|
'panel_url' => config('hosting.panel.url'),
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suspend(Request $request, int $whmcsServiceId): JsonResponse
|
||||||
|
{
|
||||||
|
$service = $this->resolveService($whmcsServiceId);
|
||||||
|
$customer = $service->customer;
|
||||||
|
|
||||||
|
if ($customer?->vmid) {
|
||||||
|
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->stopVM((int) $customer->vmid);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->update(['status' => 'suspended']);
|
||||||
|
$customer?->update(['status' => 'failed', 'provisioning_step' => 'suspended']);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Service suspended.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unsuspend(Request $request, int $whmcsServiceId): JsonResponse
|
||||||
|
{
|
||||||
|
$service = $this->resolveService($whmcsServiceId);
|
||||||
|
$customer = $service->customer;
|
||||||
|
|
||||||
|
if ($customer?->vmid) {
|
||||||
|
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)->startVM((int) $customer->vmid);
|
||||||
|
$customer->update(['status' => 'active', 'provisioning_step' => 'completed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->update(['status' => 'active']);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Service unsuspended.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function terminate(Request $request, int $whmcsServiceId, DeprovisionService $deprovision): JsonResponse
|
||||||
|
{
|
||||||
|
$service = $this->resolveService($whmcsServiceId);
|
||||||
|
$customer = $service->customer;
|
||||||
|
|
||||||
|
if ($customer) {
|
||||||
|
$deprovision->terminatePermanently($customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
$service->update(['status' => 'terminated', 'customer_id' => null]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Service terminated. VMID will be released after configured retention.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(int $whmcsServiceId): JsonResponse
|
||||||
|
{
|
||||||
|
$service = $this->resolveService($whmcsServiceId);
|
||||||
|
$service->load('customer');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'whmcs_service_id' => $service->whmcs_service_id,
|
||||||
|
'status' => $service->status,
|
||||||
|
'customer' => $service->customer ? [
|
||||||
|
'id' => $service->customer->id,
|
||||||
|
'name' => $service->customer->name,
|
||||||
|
'vmid' => $service->customer->vmid,
|
||||||
|
'status' => $service->customer->status,
|
||||||
|
'provisioning_step' => $service->customer->provisioning_step,
|
||||||
|
'ip_address' => $service->customer->ip_address,
|
||||||
|
'domain' => $service->customer->domain,
|
||||||
|
] : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveService(int $whmcsServiceId): WhmcsService
|
||||||
|
{
|
||||||
|
return WhmcsService::query()
|
||||||
|
->where('whmcs_service_id', $whmcsServiceId)
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Controllers/Auth/LoginController.php
Normal file
50
app/Http/Controllers/Auth/LoginController.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class LoginController extends Controller
|
||||||
|
{
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||||
|
return back()->withErrors(['email' => 'Ungültige Anmeldedaten.'])->onlyInput('email');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
if (! $user->is_active) {
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
return back()->withErrors(['email' => 'Ihr Konto ist deaktiviert.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$request->session()->forget('two_factor_passed');
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/Http/Controllers/Auth/TwoFactorController.php
Normal file
65
app/Http/Controllers/Auth/TwoFactorController.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Auth\TwoFactorService;
|
||||||
|
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||||
|
use BaconQrCode\Renderer\ImageRenderer;
|
||||||
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||||
|
use BaconQrCode\Writer;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class TwoFactorController extends Controller
|
||||||
|
{
|
||||||
|
public function setup(Request $request, TwoFactorService $twoFactor): View
|
||||||
|
{
|
||||||
|
$secret = $request->session()->get('two_factor_setup_secret');
|
||||||
|
if (! $secret) {
|
||||||
|
$secret = $twoFactor->generateSecret();
|
||||||
|
$request->session()->put('two_factor_setup_secret', $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qrUrl = $twoFactor->qrUrl($request->user(), $secret);
|
||||||
|
$writer = new Writer(new ImageRenderer(new RendererStyle(200), new SvgImageBackEnd));
|
||||||
|
$qrSvg = $writer->writeString($qrUrl);
|
||||||
|
|
||||||
|
return view('auth.two-factor-setup', [
|
||||||
|
'secret' => $secret,
|
||||||
|
'qrSvg' => $qrSvg,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enable(Request $request, TwoFactorService $twoFactor): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate(['code' => ['required', 'string', 'size:6']]);
|
||||||
|
$secret = $request->session()->pull('two_factor_setup_secret');
|
||||||
|
if (! $secret || ! $twoFactor->enable($request->user(), $secret, $request->input('code'))) {
|
||||||
|
return back()->withErrors(['code' => 'Ungültiger Code. Bitte erneut versuchen.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('two_factor_passed', true);
|
||||||
|
|
||||||
|
return redirect()->route('dashboard')->with('success', 'Zwei-Faktor-Authentifizierung aktiviert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function challenge(): View
|
||||||
|
{
|
||||||
|
return view('auth.two-factor-challenge');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyChallenge(Request $request, TwoFactorService $twoFactor): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate(['code' => ['required', 'string']]);
|
||||||
|
|
||||||
|
if (! $twoFactor->verify($request->user(), $request->input('code'))) {
|
||||||
|
return back()->withErrors(['code' => 'Ungültiger Authentifizierungscode.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('two_factor_passed', true);
|
||||||
|
|
||||||
|
return redirect()->intended(route('dashboard'));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Http/Controllers/Controller.php
Normal file
10
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
}
|
||||||
58
app/Http/Controllers/CustomerProvisioningController.php
Normal file
58
app/Http/Controllers/CustomerProvisioningController.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreCustomerRequest;
|
||||||
|
use App\Jobs\ProvisionCustomerJob;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class CustomerProvisioningController extends Controller
|
||||||
|
{
|
||||||
|
public function store(StoreCustomerRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$domain = $request->domain();
|
||||||
|
|
||||||
|
$customer = Customer::query()->create([
|
||||||
|
'name' => $request->validated('name'),
|
||||||
|
'domain' => $domain,
|
||||||
|
'cpu' => $request->integer('cpu', config('hosting.defaults.cpu')),
|
||||||
|
'ram' => $request->integer('ram', config('hosting.defaults.ram')),
|
||||||
|
'disk' => $request->integer('disk', config('hosting.defaults.disk')),
|
||||||
|
'status' => 'pending',
|
||||||
|
'provisioning_step' => 'queued',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProvisionCustomerJob::dispatch($customer->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Provisioning started.',
|
||||||
|
'customer' => $customer,
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Customer $customer): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['customer' => $customer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'customers' => Customer::query()->latest()->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Customer $customer): JsonResponse
|
||||||
|
{
|
||||||
|
if ($customer->status === 'pending' && $customer->provisioning_step === 'queued') {
|
||||||
|
$customer->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Queued customer removed.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Use deprovision endpoint for active customers.',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Http/Controllers/Web/Admin/SystemHealthController.php
Normal file
17
app/Http/Controllers/Web/Admin/SystemHealthController.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Hosting\Health\SystemHealthService;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class SystemHealthController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(SystemHealthService $health): View
|
||||||
|
{
|
||||||
|
return view('admin.health', [
|
||||||
|
'checks' => $health->checks(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Http/Controllers/Web/Admin/VmTemplateController.php
Normal file
72
app/Http/Controllers/Web/Admin/VmTemplateController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\VmTemplate;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class VmTemplateController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$templates = VmTemplate::query()->orderBy('name')->get();
|
||||||
|
|
||||||
|
return view('admin.templates.index', compact('templates'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('admin.templates.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug'],
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
|
||||||
|
'os_family' => ['nullable', 'string', 'max:32'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
VmTemplate::query()->create([
|
||||||
|
...$data,
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('admin.templates.index')->with('success', 'Template angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(VmTemplate $template): View
|
||||||
|
{
|
||||||
|
return view('admin.templates.edit', compact('template'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, VmTemplate $template): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'slug' => ['required', 'string', 'max:64', 'alpha_dash', 'unique:vm_templates,slug,'.$template->id],
|
||||||
|
'name' => ['required', 'string', 'max:120'],
|
||||||
|
'proxmox_template_vmid' => ['required', 'integer', 'min:100'],
|
||||||
|
'os_family' => ['nullable', 'string', 'max:32'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$template->update([
|
||||||
|
...$data,
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('admin.templates.index')->with('success', 'Template aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(VmTemplate $template): RedirectResponse
|
||||||
|
{
|
||||||
|
$template->delete();
|
||||||
|
|
||||||
|
return redirect()->route('admin.templates.index')->with('success', 'Template gelöscht.');
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Controllers/Web/DashboardController.php
Normal file
45
app/Http/Controllers/Web/DashboardController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\IpPool;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$vmQuery = Customer::query()->forUser($user);
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'vms_total' => (clone $vmQuery)->count(),
|
||||||
|
'vms_active' => (clone $vmQuery)->where('status', 'active')->count(),
|
||||||
|
'vms_pending' => (clone $vmQuery)->where('status', 'pending')->count(),
|
||||||
|
'vms_failed' => (clone $vmQuery)->where('status', 'failed')->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$pools = IpPool::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('type')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$recentVms = Customer::query()
|
||||||
|
->forUser($user)
|
||||||
|
->with(['owner', 'ipPool'])
|
||||||
|
->latest()
|
||||||
|
->limit(8)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('dashboard', [
|
||||||
|
'stats' => $stats,
|
||||||
|
'pools' => $pools,
|
||||||
|
'recentVms' => $recentVms,
|
||||||
|
'usersCount' => $user->isAdmin() ? User::query()->count() : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
app/Http/Controllers/Web/IpPoolController.php
Normal file
123
app/Http/Controllers/Web/IpPoolController.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\IpPoolType;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\IpPool;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class IpPoolController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', IpPool::class);
|
||||||
|
|
||||||
|
$pools = IpPool::query()
|
||||||
|
->withCount(['vms' => fn ($q) => $q->whereIn('status', ['pending', 'active'])])
|
||||||
|
->orderBy('type')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(function (IpPool $pool) {
|
||||||
|
$pool->setAttribute('usage_percent', $pool->totalIps() > 0
|
||||||
|
? round(($pool->usedIpsCount() / $pool->totalIps()) * 100, 1)
|
||||||
|
: 0);
|
||||||
|
|
||||||
|
return $pool;
|
||||||
|
});
|
||||||
|
|
||||||
|
$assignments = Customer::query()
|
||||||
|
->forUser($request->user())
|
||||||
|
->with(['owner', 'ipPool'])
|
||||||
|
->whereIn('status', ['pending', 'active'])
|
||||||
|
->where(fn ($q) => $q->whereNotNull('ip_address')->orWhereNotNull('public_ip'))
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('ip-pools.index', compact('pools', 'assignments'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
$this->authorize('create', IpPool::class);
|
||||||
|
|
||||||
|
return view('ip-pools.create', ['types' => IpPoolType::cases()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', IpPool::class);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'type' => ['required', Rule::enum(IpPoolType::class)],
|
||||||
|
'start_ip' => ['required', 'ip'],
|
||||||
|
'end_ip' => ['required', 'ip'],
|
||||||
|
'gateway' => ['nullable', 'ip'],
|
||||||
|
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (ip2long($data['start_ip']) > ip2long($data['end_ip'])) {
|
||||||
|
return back()->withErrors(['end_ip' => 'End-IP muss größer als Start-IP sein.'])->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
IpPool::query()->create([
|
||||||
|
...$data,
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool erstellt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(IpPool $ipPool): View
|
||||||
|
{
|
||||||
|
$this->authorize('update', $ipPool);
|
||||||
|
|
||||||
|
return view('ip-pools.edit', [
|
||||||
|
'pool' => $ipPool,
|
||||||
|
'types' => IpPoolType::cases(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, IpPool $ipPool): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $ipPool);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'type' => ['required', Rule::enum(IpPoolType::class)],
|
||||||
|
'start_ip' => ['required', 'ip'],
|
||||||
|
'end_ip' => ['required', 'ip'],
|
||||||
|
'gateway' => ['nullable', 'ip'],
|
||||||
|
'cidr' => ['required', 'integer', 'min:8', 'max:32'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ipPool->update([
|
||||||
|
...$data,
|
||||||
|
'is_active' => $request->boolean('is_active'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(IpPool $ipPool): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $ipPool);
|
||||||
|
|
||||||
|
if ($ipPool->vms()->whereIn('status', ['pending', 'active'])->exists()) {
|
||||||
|
return back()->withErrors(['pool' => 'Pool wird noch von aktiven VMs verwendet.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipPool->delete();
|
||||||
|
|
||||||
|
return redirect()->route('ip-pools.index')->with('success', 'IP-Pool gelöscht.');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Controllers/Web/IsoUploadController.php
Normal file
38
app/Http/Controllers/Web/IsoUploadController.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CustomerIsoUpload;
|
||||||
|
use App\Services\Hosting\Iso\IsoUploadService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class IsoUploadController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$uploads = CustomerIsoUpload::query()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('iso-uploads.index', compact('uploads'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, IsoUploadService $service): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'iso' => ['required', 'file', 'mimes:iso', 'max:'.((int) config('hosting.iso_upload.max_size_mb', 10240) * 1024)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service->upload($request->user(), $request->file('iso'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('iso-uploads.index')->with('success', 'ISO hochgeladen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/Http/Controllers/Web/UserController.php
Normal file
106
app/Http/Controllers/Web/UserController.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', User::class);
|
||||||
|
|
||||||
|
$users = User::query()->withCount('vms')->orderBy('name')->paginate(20);
|
||||||
|
|
||||||
|
return view('users.index', compact('users'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
$this->authorize('create', User::class);
|
||||||
|
|
||||||
|
return view('users.create', ['roles' => UserRole::cases()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('create', User::class);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||||
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
'role' => ['required', Rule::enum(UserRole::class)],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
User::query()->create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => Hash::make($data['password']),
|
||||||
|
'role' => $data['role'],
|
||||||
|
'is_active' => $request->boolean('is_active', true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', 'Benutzer erstellt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(User $user): View
|
||||||
|
{
|
||||||
|
$this->authorize('update', $user);
|
||||||
|
|
||||||
|
return view('users.edit', [
|
||||||
|
'user' => $user,
|
||||||
|
'roles' => UserRole::cases(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $user);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||||
|
'password' => ['nullable', 'confirmed', Password::defaults()],
|
||||||
|
'role' => ['required', Rule::enum(UserRole::class)],
|
||||||
|
'is_active' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->fill([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'role' => $data['role'],
|
||||||
|
'is_active' => $request->boolean('is_active'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! empty($data['password'])) {
|
||||||
|
$user->password = Hash::make($data['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', 'Benutzer aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $user);
|
||||||
|
|
||||||
|
if ($user->vms()->whereIn('status', ['pending', 'active'])->exists()) {
|
||||||
|
return back()->withErrors(['user' => 'Benutzer hat noch aktive VMs.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', 'Benutzer gelöscht.');
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Controllers/Web/VmBackupController.php
Normal file
24
app/Http/Controllers/Web/VmBackupController.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Backups\BackupService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class VmBackupController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Customer $vm, BackupService $backups): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$backups->start($vm, auth()->user());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['backup' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Backup gestartet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Controllers/Web/VmConsoleController.php
Normal file
47
app/Http/Controllers/Web/VmConsoleController.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class VmConsoleController extends Controller
|
||||||
|
{
|
||||||
|
public function create(Request $request, Customer $vm, VmManagementService $management): View
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session = $management->createConsoleSession($vm, $request->user());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return view('vms.console-error', [
|
||||||
|
'vm' => $vm,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('vms.console', [
|
||||||
|
'vm' => $vm,
|
||||||
|
'token' => $session['token'],
|
||||||
|
'wsUrl' => $session['ws_url'],
|
||||||
|
'expiresAt' => $session['expires_at'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, string $token, VmManagementService $management): View
|
||||||
|
{
|
||||||
|
$session = $management->getConsoleSession($token, $request->user());
|
||||||
|
$vm = Customer::query()->findOrFail($session['customer_id']);
|
||||||
|
$this->authorize('view', $vm);
|
||||||
|
|
||||||
|
return view('vms.console', [
|
||||||
|
'vm' => $vm,
|
||||||
|
'token' => $token,
|
||||||
|
'wsUrl' => $session['ws_url'],
|
||||||
|
'expiresAt' => now()->addMinutes(5)->timestamp,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
215
app/Http/Controllers/Web/VmController.php
Normal file
215
app/Http/Controllers/Web/VmController.php
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreVmRequest;
|
||||||
|
use App\Http\Requests\UpdateVmRequest;
|
||||||
|
use App\Jobs\ProvisionCustomerJob;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\IpPool;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\VmDevice;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use App\Services\Hosting\Provisioning\DeprovisionService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class VmController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', Customer::class);
|
||||||
|
|
||||||
|
$vms = Customer::query()
|
||||||
|
->forUser($request->user())
|
||||||
|
->with(['owner', 'ipPool'])
|
||||||
|
->when($request->query('status'), fn ($q, $status) => $q->where('status', $status))
|
||||||
|
->latest()
|
||||||
|
->paginate(15)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
|
return view('vms.index', compact('vms'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request): View
|
||||||
|
{
|
||||||
|
$this->authorize('create', Customer::class);
|
||||||
|
|
||||||
|
$isos = [];
|
||||||
|
try {
|
||||||
|
$isos = app(ProxmoxClient::class)->listIsos();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Proxmox nicht erreichbar – Formular bleibt nutzbar
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('vms.create', [
|
||||||
|
'privatePools' => IpPool::query()->where('type', 'private')->where('is_active', true)->get(),
|
||||||
|
'customers' => $request->user()->isAdmin()
|
||||||
|
? User::query()->where('role', 'customer')->orderBy('name')->get()
|
||||||
|
: collect(),
|
||||||
|
'deviceTypes' => VmDevice::typesFor($request->user()),
|
||||||
|
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
|
||||||
|
'isos' => $isos,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreVmRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$domain = $request->domain();
|
||||||
|
|
||||||
|
if ($domain && Customer::query()->where('domain', $domain)->exists()) {
|
||||||
|
return back()->withErrors(['subdomain' => 'Diese Subdomain ist bereits vergeben.'])->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $domain) {
|
||||||
|
$domain = 'direct-'.Str::slug($request->validated('name')).'-'.Str::lower(Str::random(6)).'.internal';
|
||||||
|
}
|
||||||
|
|
||||||
|
$vm = Customer::query()->create([
|
||||||
|
'user_id' => $request->ownerId(),
|
||||||
|
'name' => $request->validated('name'),
|
||||||
|
'domain' => $domain,
|
||||||
|
'behind_traefik' => $request->boolean('behind_traefik'),
|
||||||
|
'ip_pool_id' => $request->input('ip_pool_id'),
|
||||||
|
'cpu' => $request->integer('cpu'),
|
||||||
|
'ram' => $request->integer('ram'),
|
||||||
|
'disk' => $request->integer('disk'),
|
||||||
|
'attached_iso' => $request->validated('install_iso'),
|
||||||
|
'status' => 'pending',
|
||||||
|
'provisioning_step' => 'queued',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncDevices($vm, $request->input('devices', []));
|
||||||
|
|
||||||
|
ProvisionCustomerJob::dispatch($vm->id);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('vms.show', $vm)
|
||||||
|
->with('success', 'VM-Provisioning wurde gestartet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Customer $vm): View
|
||||||
|
{
|
||||||
|
$this->authorize('view', $vm);
|
||||||
|
|
||||||
|
$vm->load([
|
||||||
|
'owner', 'ipPool', 'devices',
|
||||||
|
'snapshots' => fn ($q) => $q->latest(),
|
||||||
|
'backups' => fn ($q) => $q->latest()->limit(10),
|
||||||
|
'firewallRules',
|
||||||
|
'metrics' => fn ($q) => $q->limit(48),
|
||||||
|
'activityLogs' => fn ($q) => $q->limit(15)->with('user'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$isos = [];
|
||||||
|
$liveStatus = null;
|
||||||
|
|
||||||
|
if ($vm->vmid && $vm->status === 'active') {
|
||||||
|
try {
|
||||||
|
$proxmox = app(ProxmoxClient::class);
|
||||||
|
$isos = $proxmox->listIsos();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$proxmox ??= app(ProxmoxClient::class);
|
||||||
|
$liveStatus = $proxmox->normalizeLiveStatus($proxmox->getVMStatus((int) $vm->vmid));
|
||||||
|
$vm->update([
|
||||||
|
'proxmox_status' => $liveStatus['status'],
|
||||||
|
'proxmox_uptime' => $liveStatus['uptime'],
|
||||||
|
'proxmox_status_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('vms.show', compact('vm', 'isos', 'liveStatus'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, Customer $vm): View
|
||||||
|
{
|
||||||
|
$this->authorize('update', $vm);
|
||||||
|
|
||||||
|
$vm->load('devices');
|
||||||
|
|
||||||
|
return view('vms.edit', [
|
||||||
|
'vm' => $vm,
|
||||||
|
'deviceTypes' => VmDevice::typesFor($request->user()),
|
||||||
|
'templates' => \App\Models\VmTemplate::query()->where('is_active', true)->orderBy('name')->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateVmRequest $request, Customer $vm): RedirectResponse
|
||||||
|
{
|
||||||
|
$vm->update($request->only(['name', 'cpu', 'ram', 'disk']));
|
||||||
|
|
||||||
|
if ($request->has('devices')) {
|
||||||
|
$vm->devices()->delete();
|
||||||
|
$this->syncDevices($vm, $request->input('devices', []));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($vm->vmid && $vm->status === 'active') {
|
||||||
|
try {
|
||||||
|
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
|
||||||
|
->updateVmResources((int) $vm->vmid, $vm->cpu, $vm->ram, $vm->disk);
|
||||||
|
app(\App\Services\Hosting\Proxmox\ProxmoxClient::class)
|
||||||
|
->applyDevices((int) $vm->vmid, $vm->devices()->get());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->with('warning', 'DB gespeichert, Proxmox-Update fehlgeschlagen: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('vms.show', $vm)->with('success', 'VM-Konfiguration gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Customer $vm, DeprovisionService $deprovision, \App\Services\Hosting\Snapshots\SnapshotService $snapshots): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('delete', $vm);
|
||||||
|
|
||||||
|
if ($vm->vmid && $vm->status === 'active') {
|
||||||
|
try {
|
||||||
|
$snapshots->autoBeforeDestructive($vm, $request->user(), 'delete');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($vm->status === 'pending' && $vm->provisioning_step === 'queued') {
|
||||||
|
if ($vm->vmid) {
|
||||||
|
app(\App\Services\Hosting\Provisioning\VmidReservationService::class)
|
||||||
|
->scheduleRelease((int) $vm->vmid, $vm);
|
||||||
|
}
|
||||||
|
$vm->devices()->delete();
|
||||||
|
$vm->delete();
|
||||||
|
|
||||||
|
return redirect()->route('vms.index')->with('success', 'VM-Eintrag gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$deprovision->removeVmOnly($vm, $request->user());
|
||||||
|
|
||||||
|
return redirect()->route('vms.index')->with('success', 'VM wurde entfernt. VMID wird nach 48h freigegeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncDevices(Customer $vm, array $devices): void
|
||||||
|
{
|
||||||
|
$allowed = array_keys(VmDevice::typesFor(auth()->user()));
|
||||||
|
|
||||||
|
foreach ($devices as $index => $device) {
|
||||||
|
if (empty($device['type']) || ! in_array($device['type'], $allowed, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vm->devices()->create([
|
||||||
|
'type' => $device['type'],
|
||||||
|
'slot' => $device['slot'] ?? null,
|
||||||
|
'config' => $device['config'] ?? [],
|
||||||
|
'sort_order' => $index,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Http/Controllers/Web/VmFirewallController.php
Normal file
53
app/Http/Controllers/Web/VmFirewallController.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Firewall\FirewallService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class VmFirewallController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Customer $vm, FirewallService $firewall): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'direction' => ['required', Rule::in(['in', 'out'])],
|
||||||
|
'action' => ['required', Rule::in(['ACCEPT', 'DROP', 'REJECT'])],
|
||||||
|
'protocol' => ['required', Rule::in(['tcp', 'udp', 'icmp'])],
|
||||||
|
'port' => ['nullable', 'string', 'max:32'],
|
||||||
|
'source' => ['nullable', 'string', 'max:64'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$vm->firewallRules()->create([
|
||||||
|
...$data,
|
||||||
|
'sort_order' => $vm->firewallRules()->count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$firewall->syncToProxmox($vm);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->with('warning', 'Regel gespeichert, Proxmox-Sync fehlgeschlagen: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Firewall-Regel hinzugefügt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Customer $vm, int $rule, FirewallService $firewall): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
$vm->firewallRules()->whereKey($rule)->delete();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$firewall->syncToProxmox($vm);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Regel entfernt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Http/Controllers/Web/VmIsoController.php
Normal file
55
app/Http/Controllers/Web/VmIsoController.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||||
|
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class VmIsoController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'iso_volid' => ['required', 'string', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshots->autoBeforeDestructive($vm, $request->user(), 'iso-mount');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$management->mountIso($vm, $data['iso_volid'], $request->user());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'ISO wurde eingebunden. VM ggf. neu starten, um vom Installationsmedium zu booten.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Customer $vm, VmManagementService $management): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$management->unmountIso($vm, $request->user());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['iso' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'ISO wurde entfernt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ProxmoxClient $proxmox): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return response()->json(['isos' => $proxmox->listIsos()]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()], 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Http/Controllers/Web/VmPowerController.php
Normal file
36
app/Http/Controllers/Web/VmPowerController.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Enums\VmPowerAction;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||||
|
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class VmPowerController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Customer $vm, VmManagementService $management, SnapshotService $snapshots): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
|
||||||
|
$action = VmPowerAction::from($request->validate([
|
||||||
|
'action' => ['required', Rule::enum(VmPowerAction::class)],
|
||||||
|
])['action']);
|
||||||
|
|
||||||
|
if (in_array($action, [VmPowerAction::Stop, VmPowerAction::Reset, VmPowerAction::Reboot], true)) {
|
||||||
|
$snapshots->autoBeforeDestructive($vm, $request->user(), 'power-'.$action->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$management->power($vm, $action, $request->user());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['power' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', $action->label().' wurde ausgeführt.');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Http/Controllers/Web/VmReinstallController.php
Normal file
26
app/Http/Controllers/Web/VmReinstallController.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Reinstall\ReinstallService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class VmReinstallController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Customer $vm, ReinstallService $reinstall): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
$request->validate(['confirm' => ['required', 'in:REINSTALL']]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reinstall->reinstall($vm, $request->user());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['reinstall' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('vms.show', $vm)->with('success', 'Neuinstallation gestartet.');
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Http/Controllers/Web/VmSnapshotController.php
Normal file
55
app/Http/Controllers/Web/VmSnapshotController.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\VmSnapshot;
|
||||||
|
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class VmSnapshotController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Customer $vm, SnapshotService $snapshots): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
$request->validate(['label' => ['nullable', 'string', 'max:64']]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$snapshots->create($vm, $request->user(), false, $request->input('label'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Snapshot erstellt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollback(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
abort_unless($snapshot->customer_id === $vm->id, 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$snapshots->rollback($vm, $snapshot);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Snapshot wiederhergestellt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Customer $vm, VmSnapshot $snapshot, SnapshotService $snapshots): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorize('manage', $vm);
|
||||||
|
abort_unless($snapshot->customer_id === $vm->id, 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$snapshots->delete($vm, $snapshot);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['snapshot' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Snapshot gelöscht.');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Http/Controllers/Web/VmStatusController.php
Normal file
42
app/Http/Controllers/Web/VmStatusController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class VmStatusController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Customer $vm, VmManagementService $management): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('view', $vm);
|
||||||
|
|
||||||
|
if (! $vm->vmid || $vm->status !== 'active') {
|
||||||
|
return response()->json([
|
||||||
|
'status' => $vm->status,
|
||||||
|
'proxmox' => null,
|
||||||
|
'message' => 'VM nicht bereit',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$live = $management->refreshLiveStatus($vm);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $vm->status,
|
||||||
|
'proxmox' => $live,
|
||||||
|
'attached_iso' => $vm->attached_iso,
|
||||||
|
'refreshed_at' => $vm->proxmox_status_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => $vm->status,
|
||||||
|
'proxmox' => null,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Http/Middleware/EnsureTwoFactorVerified.php
Normal file
33
app/Http/Middleware/EnsureTwoFactorVerified.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\Auth\TwoFactorService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureTwoFactorVerified
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TwoFactorService $twoFactor) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (! $user) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->twoFactor->mustSetup($user) && ! $request->routeIs('two-factor.*')) {
|
||||||
|
return redirect()->route('two-factor.setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->twoFactor->isEnabled($user) && ! $request->session()->get('two_factor_passed')) {
|
||||||
|
if (! $request->routeIs('two-factor.challenge', 'two-factor.challenge.store', 'logout')) {
|
||||||
|
return redirect()->route('two-factor.challenge');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
19
app/Http/Middleware/EnsureUserIsAdmin.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureUserIsAdmin
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! $request->user()?->isAdmin()) {
|
||||||
|
abort(403, 'Zugriff nur für Administratoren.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Http/Middleware/VerifyWhmcsRequest.php
Normal file
48
app/Http/Middleware/VerifyWhmcsRequest.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class VerifyWhmcsRequest
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! config('hosting.whmcs.enabled', false)) {
|
||||||
|
abort(503, 'WHMCS integration is disabled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = config('hosting.whmcs.api_secret');
|
||||||
|
if (empty($secret)) {
|
||||||
|
abort(500, 'WHMCS API secret is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedIps = config('hosting.whmcs.allowed_ips', []);
|
||||||
|
if ($allowedIps !== [] && ! in_array($request->ip(), $allowedIps, true)) {
|
||||||
|
abort(403, 'IP not allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = (int) $request->header('X-Whmcs-Timestamp', 0);
|
||||||
|
$signature = (string) $request->header('X-Whmcs-Signature', '');
|
||||||
|
$window = (int) config('hosting.whmcs.replay_window_seconds', 300);
|
||||||
|
|
||||||
|
if ($timestamp === 0 || $signature === '') {
|
||||||
|
abort(401, 'Missing WHMCS signature headers.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abs(time() - $timestamp) > $window) {
|
||||||
|
abort(401, 'Request timestamp expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $timestamp.'.'.$request->getContent();
|
||||||
|
$expected = hash_hmac('sha256', $payload, $secret);
|
||||||
|
|
||||||
|
if (! hash_equals($expected, $signature)) {
|
||||||
|
abort(401, 'Invalid WHMCS signature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Http/Requests/StoreCustomerRequest.php
Normal file
42
app/Http/Requests/StoreCustomerRequest.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreCustomerRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$baseDomain = config('hosting.plesk.base_domain');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||||
|
'subdomain' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:63',
|
||||||
|
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail) use ($baseDomain): void {
|
||||||
|
$domain = strtolower((string) $value).'.'.$baseDomain;
|
||||||
|
if (\App\Models\Customer::query()->where('domain', $domain)->exists()) {
|
||||||
|
$fail("The domain {$domain} is already registered.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||||
|
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
|
||||||
|
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domain(): string
|
||||||
|
{
|
||||||
|
return strtolower($this->validated('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Http/Requests/StoreVmRequest.php
Normal file
64
app/Http/Requests/StoreVmRequest.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreVmRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()->can('create', Customer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$baseDomain = config('hosting.plesk.base_domain');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||||
|
'subdomain' => [
|
||||||
|
Rule::requiredIf(fn () => $this->boolean('behind_traefik')),
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:63',
|
||||||
|
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||||
|
],
|
||||||
|
'behind_traefik' => ['boolean'],
|
||||||
|
'user_id' => [
|
||||||
|
Rule::requiredIf(fn () => $this->user()->isAdmin()),
|
||||||
|
'nullable',
|
||||||
|
'exists:users,id',
|
||||||
|
],
|
||||||
|
'ip_pool_id' => ['nullable', 'exists:ip_pools,id'],
|
||||||
|
'cpu' => ['required', 'integer', 'min:1', 'max:32'],
|
||||||
|
'ram' => ['required', 'integer', 'min:512', 'max:131072'],
|
||||||
|
'disk' => ['required', 'integer', 'min:10', 'max:2048'],
|
||||||
|
'devices' => ['nullable', 'array'],
|
||||||
|
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
|
||||||
|
'devices.*.slot' => ['nullable', 'string', 'max:32'],
|
||||||
|
'devices.*.config' => ['nullable', 'array'],
|
||||||
|
'install_iso' => ['nullable', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domain(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->boolean('behind_traefik') || ! $this->filled('subdomain')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ownerId(): int
|
||||||
|
{
|
||||||
|
if ($this->user()->isAdmin() && $this->filled('user_id')) {
|
||||||
|
return (int) $this->input('user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->user()->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/UpdateVmRequest.php
Normal file
28
app/Http/Requests/UpdateVmRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateVmRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()->can('update', $this->route('vm'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['sometimes', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||||
|
'cpu' => ['sometimes', 'integer', 'min:1', 'max:32'],
|
||||||
|
'ram' => ['sometimes', 'integer', 'min:512', 'max:131072'],
|
||||||
|
'disk' => ['sometimes', 'integer', 'min:10', 'max:2048'],
|
||||||
|
'devices' => ['nullable', 'array'],
|
||||||
|
'devices.*.type' => ['required_with:devices', Rule::in(array_keys(\App\Models\VmDevice::typesFor($this->user())))],
|
||||||
|
'devices.*.slot' => ['nullable', 'string', 'max:32'],
|
||||||
|
'devices.*.config' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Http/Requests/Whmcs/ProvisionServiceRequest.php
Normal file
57
app/Http/Requests/Whmcs/ProvisionServiceRequest.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Whmcs;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ProvisionServiceRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'whmcs_service_id' => ['required', 'integer', 'unique:whmcs_services,whmcs_service_id'],
|
||||||
|
'whmcs_client_id' => ['required', 'integer'],
|
||||||
|
'whmcs_order_id' => ['nullable', 'integer'],
|
||||||
|
'client_email' => ['required', 'email', 'max:255'],
|
||||||
|
'client_name' => ['required', 'string', 'max:100'],
|
||||||
|
'plan_slug' => ['required', 'string', 'exists:hosting_plans,slug'],
|
||||||
|
'hostname' => ['required', 'string', 'max:100', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/'],
|
||||||
|
'subdomain' => [
|
||||||
|
Rule::requiredIf(fn () => $this->boolean('behind_traefik', true)),
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:63',
|
||||||
|
'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/',
|
||||||
|
],
|
||||||
|
'behind_traefik' => ['boolean'],
|
||||||
|
'provision_mode' => ['required', Rule::in(['template', 'iso', 'empty'])],
|
||||||
|
'template_slug' => [
|
||||||
|
Rule::requiredIf(fn () => $this->input('provision_mode') === 'template'),
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:64',
|
||||||
|
],
|
||||||
|
'iso_volid' => [
|
||||||
|
Rule::requiredIf(fn () => $this->input('provision_mode') === 'iso'),
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domain(): ?string
|
||||||
|
{
|
||||||
|
if (! $this->boolean('behind_traefik', true) || ! $this->filled('subdomain')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($this->input('subdomain')).'.'.config('hosting.plesk.base_domain');
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Jobs/ProvisionCustomerJob.php
Normal file
75
app/Jobs/ProvisionCustomerJob.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Services\Hosting\DTO\CustomerProvisionData;
|
||||||
|
use App\Services\Hosting\Notifications\HostingNotificationService;
|
||||||
|
use App\Services\Hosting\Provisioning\ProvisioningService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ProvisionCustomerJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
public int $backoff = 30;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $customerId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(ProvisioningService $provisioning, HostingNotificationService $notifications): void
|
||||||
|
{
|
||||||
|
$customer = Customer::query()->with('owner')->findOrFail($this->customerId);
|
||||||
|
|
||||||
|
if ($customer->status === 'active') {
|
||||||
|
Log::info('ProvisionCustomerJob: customer already active', ['id' => $customer->id]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer->update([
|
||||||
|
'status' => 'pending',
|
||||||
|
'provisioning_step' => 'queued',
|
||||||
|
'error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = CustomerProvisionData::fromCustomer($customer);
|
||||||
|
|
||||||
|
$provisioning->provision($customer, $data);
|
||||||
|
|
||||||
|
if ($customer->owner) {
|
||||||
|
$notifications->provisioningCompleted($customer->fresh(), $customer->owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(?Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('ProvisionCustomerJob failed permanently', [
|
||||||
|
'customer_id' => $this->customerId,
|
||||||
|
'error' => $exception?->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$customer = Customer::query()->with('owner')->find($this->customerId);
|
||||||
|
if ($customer) {
|
||||||
|
$customer->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'error_message' => $exception?->getMessage() ?? 'Unknown provisioning failure',
|
||||||
|
]);
|
||||||
|
if ($customer->owner) {
|
||||||
|
app(HostingNotificationService::class)->provisioningFailed(
|
||||||
|
$customer,
|
||||||
|
$customer->owner,
|
||||||
|
$customer->error_message ?? 'Unbekannter Fehler',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Models/Customer.php
Normal file
129
app/Models/Customer.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Customer extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'customers';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'ip_pool_id',
|
||||||
|
'name',
|
||||||
|
'domain',
|
||||||
|
'vmid',
|
||||||
|
'ip_address',
|
||||||
|
'public_ip',
|
||||||
|
'behind_traefik',
|
||||||
|
'provision_mode',
|
||||||
|
'hosting_plan_id',
|
||||||
|
'whmcs_service_id',
|
||||||
|
'cpu',
|
||||||
|
'ram',
|
||||||
|
'disk',
|
||||||
|
'attached_iso',
|
||||||
|
'proxmox_status',
|
||||||
|
'proxmox_uptime',
|
||||||
|
'proxmox_status_at',
|
||||||
|
'status',
|
||||||
|
'provisioning_step',
|
||||||
|
'error_message',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'vmid' => 'integer',
|
||||||
|
'cpu' => 'integer',
|
||||||
|
'ram' => 'integer',
|
||||||
|
'disk' => 'integer',
|
||||||
|
'behind_traefik' => 'boolean',
|
||||||
|
'proxmox_uptime' => 'integer',
|
||||||
|
'proxmox_status_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function owner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ipPool(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IpPool::class, 'ip_pool_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function plan(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(HostingPlan::class, 'hosting_plan_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function whmcsService(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WhmcsService::class, 'whmcs_service_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vmidReservation(): \Illuminate\Database\Eloquent\Relations\HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(VmidReservation::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function devices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VmDevice::class)->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function snapshots(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VmSnapshot::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backups(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VmBackup::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function firewallRules(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VmFirewallRule::class)->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function metrics(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VmMetric::class)->orderByDesc('recorded_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activityLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(VmActivityLog::class)->latest();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRunning(): bool
|
||||||
|
{
|
||||||
|
return $this->proxmox_status === 'running';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForUser(Builder $query, User $user): Builder
|
||||||
|
{
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('user_id', $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isProvisionable(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, ['pending', 'failed'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayName(): string
|
||||||
|
{
|
||||||
|
return $this->name.($this->vmid ? " (#{$this->vmid})" : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/CustomerIsoUpload.php
Normal file
30
app/Models/CustomerIsoUpload.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class CustomerIsoUpload extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'filename',
|
||||||
|
'volid',
|
||||||
|
'size_bytes',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'size_bytes' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Models/HostingPlan.php
Normal file
41
app/Models/HostingPlan.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class HostingPlan extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
'name',
|
||||||
|
'cpu',
|
||||||
|
'ram',
|
||||||
|
'disk',
|
||||||
|
'max_backups',
|
||||||
|
'allow_public_ip',
|
||||||
|
'allow_iso_upload',
|
||||||
|
'is_active',
|
||||||
|
'whmcs_product_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cpu' => 'integer',
|
||||||
|
'ram' => 'integer',
|
||||||
|
'disk' => 'integer',
|
||||||
|
'max_backups' => 'integer',
|
||||||
|
'allow_public_ip' => 'boolean',
|
||||||
|
'allow_iso_upload' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'whmcs_product_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customers(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Customer::class, 'hosting_plan_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Models/IpPool.php
Normal file
74
app/Models/IpPool.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\IpPoolType;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class IpPool extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'type',
|
||||||
|
'start_ip',
|
||||||
|
'end_ip',
|
||||||
|
'gateway',
|
||||||
|
'cidr',
|
||||||
|
'description',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => IpPoolType::class,
|
||||||
|
'cidr' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vms(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Customer::class, 'ip_pool_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totalIps(): int
|
||||||
|
{
|
||||||
|
$start = ip2long($this->start_ip);
|
||||||
|
$end = ip2long($this->end_ip);
|
||||||
|
|
||||||
|
if ($start === false || $end === false || $end < $start) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($end - $start + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function usedIpsCount(): int
|
||||||
|
{
|
||||||
|
$query = Customer::query()
|
||||||
|
->where('ip_pool_id', $this->id)
|
||||||
|
->whereIn('status', ['pending', 'active']);
|
||||||
|
|
||||||
|
if ($this->type === IpPoolType::Public) {
|
||||||
|
return $query->whereNotNull('public_ip')->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->whereNotNull('ip_address')->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function freeIpsCount(): int
|
||||||
|
{
|
||||||
|
return max(0, $this->totalIps() - $this->usedIpsCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function containsIp(string $ip): bool
|
||||||
|
{
|
||||||
|
$long = ip2long($ip);
|
||||||
|
$start = ip2long($this->start_ip);
|
||||||
|
$end = ip2long($this->end_ip);
|
||||||
|
|
||||||
|
return $long !== false && $long >= $start && $long <= $end;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/SystemSetting.php
Normal file
32
app/Models/SystemSetting.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class SystemSetting extends Model
|
||||||
|
{
|
||||||
|
protected $primaryKey = 'key';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $fillable = ['key', 'value'];
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return Cache::remember("setting.{$key}", 300, function () use ($key, $default) {
|
||||||
|
$row = static::query()->find($key);
|
||||||
|
|
||||||
|
return $row?->value ?? $default;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
static::query()->updateOrCreate(['key' => $key], ['value' => (string) $value]);
|
||||||
|
Cache::forget("setting.{$key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Models/User.php
Normal file
46
app/Models/User.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
|
#[Fillable(['name', 'email', 'password', 'role', 'is_active', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at'])]
|
||||||
|
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<UserFactory> */
|
||||||
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
'role' => UserRole::class,
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vms(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Customer::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->role === UserRole::Admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isCustomer(): bool
|
||||||
|
{
|
||||||
|
return $this->role === UserRole::Customer;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Models/VmActivityLog.php
Normal file
35
app/Models/VmActivityLog.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VmActivityLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id',
|
||||||
|
'user_id',
|
||||||
|
'action',
|
||||||
|
'status',
|
||||||
|
'message',
|
||||||
|
'meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class, 'customer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/VmBackup.php
Normal file
26
app/Models/VmBackup.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VmBackup extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id', 'user_id', 'storage', 'volume_id', 'status', 'size_bytes', 'completed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'size_bytes' => 'integer',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class, 'customer_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Models/VmDevice.php
Normal file
58
app/Models/VmDevice.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VmDevice extends Model
|
||||||
|
{
|
||||||
|
public const TYPE_DISK = 'disk';
|
||||||
|
|
||||||
|
public const TYPE_NETWORK = 'network';
|
||||||
|
|
||||||
|
public const TYPE_USB = 'usb';
|
||||||
|
|
||||||
|
public const TYPE_PCI = 'pci';
|
||||||
|
|
||||||
|
public static function types(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::TYPE_DISK => 'Zusätzliche Festplatte',
|
||||||
|
self::TYPE_NETWORK => 'Netzwerk-Interface',
|
||||||
|
self::TYPE_USB => 'USB-Gerät',
|
||||||
|
self::TYPE_PCI => 'PCI Passthrough',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function typesFor(?\App\Models\User $user = null): array
|
||||||
|
{
|
||||||
|
$types = self::types();
|
||||||
|
if ($user && ! $user->isAdmin()) {
|
||||||
|
return array_intersect_key($types, array_flip([self::TYPE_DISK, self::TYPE_NETWORK]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $types;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id',
|
||||||
|
'type',
|
||||||
|
'slot',
|
||||||
|
'config',
|
||||||
|
'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'config' => 'array',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class, 'customer_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/VmFirewallRule.php
Normal file
26
app/Models/VmFirewallRule.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VmFirewallRule extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id', 'direction', 'action', 'protocol', 'port', 'source', 'is_active', 'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class, 'customer_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/VmMetric.php
Normal file
32
app/Models/VmMetric.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VmMetric extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id', 'cpu', 'mem', 'maxmem', 'disk', 'maxdisk', 'recorded_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cpu' => 'float',
|
||||||
|
'mem' => 'integer',
|
||||||
|
'maxmem' => 'integer',
|
||||||
|
'disk' => 'integer',
|
||||||
|
'maxdisk' => 'integer',
|
||||||
|
'recorded_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class, 'customer_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/VmSnapshot.php
Normal file
26
app/Models/VmSnapshot.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VmSnapshot extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id', 'name', 'proxmox_snapshot_id', 'auto_created', 'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'auto_created' => 'boolean',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vm(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class, 'customer_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Models/VmTemplate.php
Normal file
20
app/Models/VmTemplate.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class VmTemplate extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'slug', 'name', 'proxmox_template_vmid', 'os_family', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'proxmox_template_vmid' => 'integer',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Models/VmidReservation.php
Normal file
36
app/Models/VmidReservation.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class VmidReservation extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'vmid',
|
||||||
|
'customer_id',
|
||||||
|
'status',
|
||||||
|
'release_at',
|
||||||
|
'released_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'vmid' => 'integer',
|
||||||
|
'release_at' => 'datetime',
|
||||||
|
'released_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBlocking(int $vmid): bool
|
||||||
|
{
|
||||||
|
return in_array($this->status, ['reserved', 'active', 'pending_release'], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Models/WhmcsService.php
Normal file
45
app/Models/WhmcsService.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WhmcsService extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'whmcs_service_id',
|
||||||
|
'whmcs_client_id',
|
||||||
|
'whmcs_order_id',
|
||||||
|
'customer_id',
|
||||||
|
'user_id',
|
||||||
|
'hosting_plan_id',
|
||||||
|
'status',
|
||||||
|
'config',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'whmcs_service_id' => 'integer',
|
||||||
|
'whmcs_client_id' => 'integer',
|
||||||
|
'whmcs_order_id' => 'integer',
|
||||||
|
'config' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function plan(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(HostingPlan::class, 'hosting_plan_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Policies/IpPoolPolicy.php
Normal file
34
app/Policies/IpPoolPolicy.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\IpPool;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class IpPoolPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, IpPool $ipPool): bool
|
||||||
|
{
|
||||||
|
return $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() && $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, IpPool $ipPool): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() && $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, IpPool $ipPool): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() && $user->is_active;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Policies/UserPolicy.php
Normal file
28
app/Policies/UserPolicy.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class UserPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() && $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() && $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() && $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, User $model): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin() && $user->is_active && $user->id !== $model->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Policies/VmPolicy.php
Normal file
39
app/Policies/VmPolicy.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class VmPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Customer $vm): bool
|
||||||
|
{
|
||||||
|
return $user->is_active && ($user->isAdmin() || $vm->user_id === $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->is_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Customer $vm): bool
|
||||||
|
{
|
||||||
|
return $user->is_active && ($user->isAdmin() || $vm->user_id === $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Customer $vm): bool
|
||||||
|
{
|
||||||
|
return $user->is_active && ($user->isAdmin() || $vm->user_id === $user->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function manage(User $user, Customer $vm): bool
|
||||||
|
{
|
||||||
|
return $this->update($user, $vm) && $vm->status === 'active' && $vm->vmid !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Providers/AppServiceProvider.php
Normal file
36
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\IpPool;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Policies\IpPoolPolicy;
|
||||||
|
use App\Policies\UserPolicy;
|
||||||
|
use App\Policies\VmPolicy;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('login', function (Request $request) {
|
||||||
|
$max = (int) config('hosting.security.login_max_attempts', 5);
|
||||||
|
|
||||||
|
return Limit::perMinute($max)->by($request->ip().'|'.$request->input('email'));
|
||||||
|
});
|
||||||
|
|
||||||
|
Gate::policy(Customer::class, VmPolicy::class);
|
||||||
|
Gate::policy(IpPool::class, IpPoolPolicy::class);
|
||||||
|
Gate::policy(User::class, UserPolicy::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Providers/HostingServiceProvider.php
Normal file
36
app/Providers/HostingServiceProvider.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Services\Hosting\Plesk\PleskClient;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use App\Services\Hosting\Provisioning\IpAddressAllocator;
|
||||||
|
use App\Services\Hosting\Proxmox\VmManagementService;
|
||||||
|
use App\Services\Hosting\Provisioning\ProvisioningService;
|
||||||
|
use App\Services\Hosting\Traefik\TraefikGenerator;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class HostingServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->singleton(ProxmoxClient::class);
|
||||||
|
$this->app->singleton(PleskClient::class);
|
||||||
|
$this->app->singleton(TraefikGenerator::class);
|
||||||
|
$this->app->singleton(IpAddressAllocator::class);
|
||||||
|
$this->app->singleton(ProvisioningService::class);
|
||||||
|
$this->app->singleton(VmManagementService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('vm-power', function (Request $request) {
|
||||||
|
$limit = (int) config('hosting.vm_power.rate_limit_per_minute', 20);
|
||||||
|
|
||||||
|
return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Services/Auth/TwoFactorService.php
Normal file
87
app/Services/Auth/TwoFactorService.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
|
|
||||||
|
class TwoFactorService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Google2FA $google2fa) {}
|
||||||
|
|
||||||
|
public function generateSecret(): string
|
||||||
|
{
|
||||||
|
return $this->google2fa->generateSecretKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function qrUrl(User $user, string $secret): string
|
||||||
|
{
|
||||||
|
$company = config('app.name', 'HexaHost Panel');
|
||||||
|
|
||||||
|
return $this->google2fa->getQRCodeUrl($company, $user->email, $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(User $user, string $code): bool
|
||||||
|
{
|
||||||
|
$secret = $this->decryptSecret($user);
|
||||||
|
if (! $secret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->google2fa->verifyKey($secret, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enable(User $user, string $secret, string $code): bool
|
||||||
|
{
|
||||||
|
if (! $this->google2fa->verifyKey($secret, $code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recovery = collect(range(1, 8))->map(fn () => bin2hex(random_bytes(4)))->implode(',');
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'two_factor_secret' => Crypt::encryptString($secret),
|
||||||
|
'two_factor_recovery_codes' => Crypt::encryptString($recovery),
|
||||||
|
'two_factor_confirmed_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disable(User $user): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'two_factor_secret' => null,
|
||||||
|
'two_factor_recovery_codes' => null,
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->two_factor_confirmed_at !== null && $user->two_factor_secret !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mustSetup(User $user): bool
|
||||||
|
{
|
||||||
|
if (! config('hosting.security.admin_2fa_required', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->isAdmin() && ! $this->isEnabled($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decryptSecret(User $user): ?string
|
||||||
|
{
|
||||||
|
if (! $user->two_factor_secret) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Crypt::decryptString($user->two_factor_secret);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Services/Hosting/Backups/BackupService.php
Normal file
73
app/Services/Hosting/Backups/BackupService.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Backups;
|
||||||
|
|
||||||
|
use App\Exceptions\Hosting\ProvisioningException;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\VmBackup;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class BackupService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProxmoxClient $proxmox) {}
|
||||||
|
|
||||||
|
public function start(Customer $vm, User $user): VmBackup
|
||||||
|
{
|
||||||
|
if (! config('hosting.backups.enabled', false)) {
|
||||||
|
throw new ProvisioningException('Backups sind noch nicht aktiviert (PBS-Rechte fehlen).', step: 'backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
$max = $this->maxBackupsForUser($user);
|
||||||
|
$count = VmBackup::query()
|
||||||
|
->whereHas('vm', fn ($q) => $q->where('user_id', $user->id))
|
||||||
|
->whereIn('status', ['running', 'completed'])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($count >= $max) {
|
||||||
|
throw new ProvisioningException("Maximal {$max} Backups erlaubt.", step: 'backup_limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = config('hosting.backups.pbs_storage', 'inett-PBS');
|
||||||
|
$upid = $this->proxmox->startBackup((int) $vm->vmid, $storage);
|
||||||
|
|
||||||
|
return VmBackup::query()->create([
|
||||||
|
'customer_id' => $vm->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'storage' => $storage,
|
||||||
|
'volume_id' => $upid,
|
||||||
|
'status' => 'running',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxBackupsForUser(User $user): int
|
||||||
|
{
|
||||||
|
return (int) config('hosting.backups.max_per_customer', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAllForVm(Customer $vm): void
|
||||||
|
{
|
||||||
|
$backups = VmBackup::query()->where('customer_id', $vm->id)->get();
|
||||||
|
|
||||||
|
foreach ($backups as $backup) {
|
||||||
|
Log::info('Backup record removed', ['id' => $backup->id, 'volume' => $backup->volume_id]);
|
||||||
|
// PBS purge via API when credentials available
|
||||||
|
$backup->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enforceRetentionForUser(User $user): void
|
||||||
|
{
|
||||||
|
$max = $this->maxBackupsForUser($user);
|
||||||
|
$backups = VmBackup::query()
|
||||||
|
->whereHas('vm', fn ($q) => $q->where('user_id', $user->id))
|
||||||
|
->where('status', 'completed')
|
||||||
|
->orderByDesc('completed_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($backups->slice($max) as $old) {
|
||||||
|
$old->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Services/Hosting/DTO/CustomerProvisionData.php
Normal file
74
app/Services/Hosting/DTO/CustomerProvisionData.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\DTO;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
|
||||||
|
readonly class CustomerProvisionData
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $customer_name,
|
||||||
|
public ?string $domain,
|
||||||
|
public ?int $vmid = null,
|
||||||
|
public ?string $ip = null,
|
||||||
|
public ?string $public_ip = null,
|
||||||
|
public int $cpu = 2,
|
||||||
|
public int $ram = 2048,
|
||||||
|
public int $disk = 32,
|
||||||
|
public bool $behind_traefik = true,
|
||||||
|
public ?int $ip_pool_id = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromCustomer(Customer $customer): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
customer_name: $customer->name,
|
||||||
|
domain: $customer->domain,
|
||||||
|
vmid: $customer->vmid,
|
||||||
|
ip: $customer->ip_address,
|
||||||
|
public_ip: $customer->public_ip,
|
||||||
|
cpu: $customer->cpu,
|
||||||
|
ram: $customer->ram,
|
||||||
|
disk: $customer->disk,
|
||||||
|
behind_traefik: $customer->behind_traefik,
|
||||||
|
ip_pool_id: $customer->ip_pool_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
customer_name: (string) $data['customer_name'],
|
||||||
|
domain: isset($data['domain']) ? (string) $data['domain'] : null,
|
||||||
|
vmid: isset($data['vmid']) ? (int) $data['vmid'] : null,
|
||||||
|
ip: isset($data['ip']) ? (string) $data['ip'] : null,
|
||||||
|
public_ip: isset($data['public_ip']) ? (string) $data['public_ip'] : null,
|
||||||
|
cpu: (int) ($data['cpu'] ?? config('hosting.defaults.cpu', 2)),
|
||||||
|
ram: (int) ($data['ram'] ?? config('hosting.defaults.ram', 2048)),
|
||||||
|
disk: (int) ($data['disk'] ?? config('hosting.defaults.disk', 32)),
|
||||||
|
behind_traefik: (bool) ($data['behind_traefik'] ?? true),
|
||||||
|
ip_pool_id: isset($data['ip_pool_id']) ? (int) $data['ip_pool_id'] : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function subdomain(): string
|
||||||
|
{
|
||||||
|
if (! $this->domain) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = config('hosting.plesk.base_domain');
|
||||||
|
$suffix = '.'.$base;
|
||||||
|
|
||||||
|
if (str_ends_with($this->domain, $suffix)) {
|
||||||
|
return substr($this->domain, 0, -strlen($suffix));
|
||||||
|
}
|
||||||
|
|
||||||
|
return explode('.', $this->domain)[0] ?? $this->customer_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresTraefik(): bool
|
||||||
|
{
|
||||||
|
return $this->behind_traefik && $this->domain !== null && $this->domain !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Services/Hosting/Firewall/FirewallService.php
Normal file
33
app/Services/Hosting/Firewall/FirewallService.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Firewall;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\VmFirewallRule;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
|
||||||
|
class FirewallService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProxmoxClient $proxmox) {}
|
||||||
|
|
||||||
|
public function syncToProxmox(Customer $vm): void
|
||||||
|
{
|
||||||
|
if (! $vm->vmid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = $vm->firewallRules()->where('is_active', true)->orderBy('sort_order')->get();
|
||||||
|
$payload = $rules->map(fn (VmFirewallRule $r) => [
|
||||||
|
'type' => $r->direction === 'out' ? 'out' : 'in',
|
||||||
|
'action' => $r->action,
|
||||||
|
'proto' => $r->protocol,
|
||||||
|
'dport' => $r->port,
|
||||||
|
'source' => $r->source,
|
||||||
|
'enable' => 1,
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
if ($payload !== []) {
|
||||||
|
$this->proxmox->setFirewallRules((int) $vm->vmid, $payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Services/Hosting/Health/SystemHealthService.php
Normal file
71
app/Services/Hosting/Health/SystemHealthService.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Health;
|
||||||
|
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
|
class SystemHealthService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProxmoxClient $proxmox) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{status: string, message: string}>
|
||||||
|
*/
|
||||||
|
public function checks(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'database' => $this->checkDatabase(),
|
||||||
|
'proxmox' => $this->checkProxmox(),
|
||||||
|
'traefik_config' => $this->checkTraefik(),
|
||||||
|
'queue' => $this->checkQueue(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkDatabase(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
DB::connection()->getPdo();
|
||||||
|
|
||||||
|
return ['status' => 'ok', 'message' => 'Datenbank erreichbar'];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['status' => 'error', 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkProxmox(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->proxmox->node();
|
||||||
|
|
||||||
|
return ['status' => 'ok', 'message' => 'Proxmox API erreichbar'];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['status' => 'error', 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkTraefik(): array
|
||||||
|
{
|
||||||
|
$path = config('hosting.traefik.dynamic_config_path');
|
||||||
|
if (! $path) {
|
||||||
|
return ['status' => 'warning', 'message' => 'Traefik-Pfad nicht konfiguriert'];
|
||||||
|
}
|
||||||
|
if (! is_writable(dirname($path)) && ! file_exists($path)) {
|
||||||
|
return ['status' => 'warning', 'message' => 'Traefik-Verzeichnis nicht beschreibbar'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => 'ok', 'message' => is_file($path) ? 'Konfigurationsdatei vorhanden' : 'Wird bei erster Route angelegt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkQueue(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$size = DB::table('jobs')->count();
|
||||||
|
|
||||||
|
return ['status' => 'ok', 'message' => "Queue: {$size} ausstehende Jobs"];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return ['status' => 'warning', 'message' => 'Queue-Tabelle nicht verfügbar (sync driver?)'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Services/Hosting/Iso/IsoUploadService.php
Normal file
67
app/Services/Hosting/Iso/IsoUploadService.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Iso;
|
||||||
|
|
||||||
|
use App\Exceptions\Hosting\ProvisioningException;
|
||||||
|
use App\Models\CustomerIsoUpload;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
|
class IsoUploadService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProxmoxClient $proxmox) {}
|
||||||
|
|
||||||
|
public function upload(User $user, UploadedFile $file): CustomerIsoUpload
|
||||||
|
{
|
||||||
|
if (! config('hosting.iso_upload.enabled', true)) {
|
||||||
|
throw new ProvisioningException('ISO-Upload ist deaktiviert.', step: 'iso_upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxMb = (int) config('hosting.iso_upload.max_size_mb', 10240);
|
||||||
|
if ($file->getSize() > $maxMb * 1024 * 1024) {
|
||||||
|
throw new ProvisioningException("Maximale ISO-Größe: {$maxMb} MB.", step: 'iso_upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxPerCustomer = (int) config('hosting.iso_upload.max_per_customer', 1);
|
||||||
|
$existing = CustomerIsoUpload::query()->where('user_id', $user->id)->where('expires_at', '>', now())->count();
|
||||||
|
if ($existing >= $maxPerCustomer) {
|
||||||
|
throw new ProvisioningException('Nur eine aktive ISO pro Kunde erlaubt.', step: 'iso_upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storage = config('hosting.proxmox.iso_storage', 'ISO');
|
||||||
|
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $file->getClientOriginalName()) ?: 'upload.iso';
|
||||||
|
$path = $file->getRealPath();
|
||||||
|
|
||||||
|
$this->proxmox->uploadIsoToStorage($storage, $safeName, $path);
|
||||||
|
|
||||||
|
$volid = "{$storage}:iso/{$safeName}";
|
||||||
|
$hours = (int) config('hosting.iso_upload.retention_hours', 48);
|
||||||
|
|
||||||
|
return CustomerIsoUpload::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'filename' => $safeName,
|
||||||
|
'volid' => $volid,
|
||||||
|
'size_bytes' => $file->getSize(),
|
||||||
|
'expires_at' => now()->addHours($hours),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purgeExpired(): int
|
||||||
|
{
|
||||||
|
$storage = config('hosting.proxmox.iso_storage', 'ISO');
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach (CustomerIsoUpload::query()->where('expires_at', '<=', now())->get() as $upload) {
|
||||||
|
try {
|
||||||
|
$this->proxmox->deleteStorageFile($storage, $upload->volid);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
$upload->delete();
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Services/Hosting/Metrics/MetricsCollectorService.php
Normal file
46
app/Services/Hosting/Metrics/MetricsCollectorService.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Metrics;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\VmMetric;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
|
||||||
|
class MetricsCollectorService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProxmoxClient $proxmox) {}
|
||||||
|
|
||||||
|
public function collectAll(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
$vms = Customer::query()->where('status', 'active')->whereNotNull('vmid')->get();
|
||||||
|
|
||||||
|
foreach ($vms as $vm) {
|
||||||
|
try {
|
||||||
|
$raw = $this->proxmox->getVMStatus((int) $vm->vmid);
|
||||||
|
$normalized = $this->proxmox->normalizeLiveStatus($raw);
|
||||||
|
|
||||||
|
VmMetric::query()->create([
|
||||||
|
'customer_id' => $vm->id,
|
||||||
|
'cpu' => $normalized['cpu'],
|
||||||
|
'mem' => $normalized['mem'],
|
||||||
|
'maxmem' => $normalized['maxmem'],
|
||||||
|
'disk' => $normalized['disk'],
|
||||||
|
'maxdisk' => $normalized['maxdisk'],
|
||||||
|
'recorded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
VmMetric::query()
|
||||||
|
->where('customer_id', $vm->id)
|
||||||
|
->where('recorded_at', '<', now()->subDays(7))
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
class HostingNotificationService
|
||||||
|
{
|
||||||
|
public function provisioningCompleted(Customer $vm, User $user): void
|
||||||
|
{
|
||||||
|
$this->send($user, 'VM bereit: '.$vm->name, "Ihre VM {$vm->name} wurde erfolgreich provisioniert.", [
|
||||||
|
'event' => 'provisioning.completed',
|
||||||
|
'customer_id' => $vm->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provisioningFailed(Customer $vm, User $user, string $error): void
|
||||||
|
{
|
||||||
|
$this->send($user, 'Provisioning fehlgeschlagen: '.$vm->name, $error, [
|
||||||
|
'event' => 'provisioning.failed',
|
||||||
|
'customer_id' => $vm->id,
|
||||||
|
'error' => $error,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vmDown(Customer $vm, User $user): void
|
||||||
|
{
|
||||||
|
$this->send($user, 'VM offline: '.$vm->name, "Ihre VM {$vm->name} ist nicht mehr erreichbar (Proxmox).", [
|
||||||
|
'event' => 'vm.down',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function send(User $user, string $subject, string $body, array $webhookPayload): void
|
||||||
|
{
|
||||||
|
if (config('hosting.plesk.mail_enabled', true)) {
|
||||||
|
try {
|
||||||
|
Mail::raw($body, fn ($m) => $m->to($user->email)->subject($subject));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Mail send failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = config('hosting.notifications.webhook_url');
|
||||||
|
if ($url) {
|
||||||
|
try {
|
||||||
|
Http::timeout(10)->post($url, array_merge($webhookPayload, [
|
||||||
|
'email' => $user->email,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
]));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Webhook failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
app/Services/Hosting/Plesk/PleskClient.php
Normal file
219
app/Services/Hosting/Plesk/PleskClient.php
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Plesk;
|
||||||
|
|
||||||
|
use App\Exceptions\Hosting\PleskException;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PleskClient
|
||||||
|
{
|
||||||
|
private PendingRequest $http;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$user = config('hosting.plesk.user');
|
||||||
|
$password = config('hosting.plesk.password');
|
||||||
|
|
||||||
|
if (empty($user) || empty($password)) {
|
||||||
|
throw new PleskException('PLESK_USER and PLESK_PASS must be configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$verify = filter_var(config('hosting.plesk.verify_ssl', true), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
|
||||||
|
$this->http = Http::baseUrl(rtrim(config('hosting.plesk.url'), '/').'/api/v2')
|
||||||
|
->withBasicAuth($user, $password)
|
||||||
|
->acceptJson()
|
||||||
|
->timeout((int) config('hosting.plesk.timeout', 30))
|
||||||
|
->when(! $verify, fn (PendingRequest $request) => $request->withoutVerifying());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createARecord(string $domain, string $subdomain, string $ip): array
|
||||||
|
{
|
||||||
|
$this->validateDomain($domain);
|
||||||
|
$this->validateSubdomain($subdomain);
|
||||||
|
$this->validateIp($ip);
|
||||||
|
|
||||||
|
$baseDomain = config('hosting.plesk.base_domain');
|
||||||
|
$zoneDomain = $this->resolveZoneDomain($domain, $baseDomain);
|
||||||
|
$host = $this->resolveHostLabel($domain, $subdomain, $baseDomain);
|
||||||
|
|
||||||
|
if ($this->findARecord($zoneDomain, $host) !== null) {
|
||||||
|
throw new PleskException(
|
||||||
|
"DNS A record already exists for {$host}.{$zoneDomain}",
|
||||||
|
step: 'plesk_dns_exists',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetIp = config('hosting.traefik.public_ip') ?: $ip;
|
||||||
|
|
||||||
|
Log::info('Plesk: creating A record', [
|
||||||
|
'zone' => $zoneDomain,
|
||||||
|
'host' => $host,
|
||||||
|
'ip' => $targetIp,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->http->post("/domains/{$zoneDomain}/dns/records", [
|
||||||
|
'type' => 'A',
|
||||||
|
'host' => $host === '@' ? '' : $host,
|
||||||
|
'value' => $targetIp,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new PleskException(
|
||||||
|
'Plesk create A record failed: '.$response->body(),
|
||||||
|
step: 'plesk_create',
|
||||||
|
context: ['status' => $response->status()],
|
||||||
|
code: $response->status(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteARecord(string $domain, string $subdomain): void
|
||||||
|
{
|
||||||
|
$baseDomain = config('hosting.plesk.base_domain');
|
||||||
|
$zoneDomain = $this->resolveZoneDomain($domain, $baseDomain);
|
||||||
|
$host = $this->resolveHostLabel($domain, $subdomain, $baseDomain);
|
||||||
|
|
||||||
|
$record = $this->findARecord($zoneDomain, $host);
|
||||||
|
|
||||||
|
if ($record === null) {
|
||||||
|
Log::warning('Plesk: A record not found for deletion', compact('zoneDomain', 'host'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordId = $record['id'] ?? null;
|
||||||
|
|
||||||
|
if ($recordId === null) {
|
||||||
|
throw new PleskException('Plesk record id missing.', step: 'plesk_delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Plesk: deleting A record', ['zone' => $zoneDomain, 'id' => $recordId]);
|
||||||
|
|
||||||
|
$response = $this->http->delete("/domains/{$zoneDomain}/dns/records/{$recordId}");
|
||||||
|
|
||||||
|
if ($response->failed() && $response->status() !== 404) {
|
||||||
|
throw new PleskException(
|
||||||
|
'Plesk delete A record failed: '.$response->body(),
|
||||||
|
step: 'plesk_delete',
|
||||||
|
code: $response->status(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listRecords(string $domain): array
|
||||||
|
{
|
||||||
|
$zoneDomain = $this->resolveZoneDomain($domain, config('hosting.plesk.base_domain'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->http->get("/domains/{$zoneDomain}/dns/records");
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new PleskException(
|
||||||
|
'Plesk list records failed: '.$response->body(),
|
||||||
|
step: 'plesk_list',
|
||||||
|
code: $response->status(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json() ?? [];
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
throw new PleskException(
|
||||||
|
'Plesk connection failed: '.$e->getMessage(),
|
||||||
|
step: 'plesk_connection',
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dnsExists(string $domain, string $subdomain): bool
|
||||||
|
{
|
||||||
|
$zoneDomain = $this->resolveZoneDomain($domain, config('hosting.plesk.base_domain'));
|
||||||
|
$host = $this->resolveHostLabel($domain, $subdomain, config('hosting.plesk.base_domain'));
|
||||||
|
|
||||||
|
return $this->findARecord($zoneDomain, $host) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findARecord(string $zoneDomain, string $host): ?array
|
||||||
|
{
|
||||||
|
$records = $this->listRecords($zoneDomain);
|
||||||
|
$items = $records['records'] ?? (is_array($records) && array_is_list($records) ? $records : []);
|
||||||
|
|
||||||
|
foreach ($items as $record) {
|
||||||
|
if (! is_array($record)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = strtoupper((string) ($record['type'] ?? ''));
|
||||||
|
$recordHost = (string) ($record['host'] ?? $record['name'] ?? '');
|
||||||
|
|
||||||
|
if ($type !== 'A') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedHost = $recordHost === '' ? '@' : rtrim($recordHost, '.');
|
||||||
|
|
||||||
|
if ($normalizedHost === $host || $normalizedHost === "{$host}.{$zoneDomain}") {
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveZoneDomain(string $domain, string $baseDomain): string
|
||||||
|
{
|
||||||
|
if (str_ends_with($domain, '.'.$baseDomain) || $domain === $baseDomain) {
|
||||||
|
return $baseDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveHostLabel(string $domain, string $subdomain, string $baseDomain): string
|
||||||
|
{
|
||||||
|
if ($subdomain !== '' && $subdomain !== '@') {
|
||||||
|
return $subdomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($domain === $baseDomain) {
|
||||||
|
return '@';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($domain, '.'.$baseDomain)) {
|
||||||
|
return substr($domain, 0, -(strlen($baseDomain) + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return explode('.', $domain)[0] ?? $subdomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateDomain(string $domain): void
|
||||||
|
{
|
||||||
|
if (! preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $domain)) {
|
||||||
|
throw new PleskException("Invalid domain: {$domain}", step: 'plesk_validation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateSubdomain(string $subdomain): void
|
||||||
|
{
|
||||||
|
if ($subdomain === '' || $subdomain === '@') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i', $subdomain)) {
|
||||||
|
throw new PleskException("Invalid subdomain: {$subdomain}", step: 'plesk_validation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateIp(string $ip): void
|
||||||
|
{
|
||||||
|
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
|
throw new PleskException("Invalid IP: {$ip}", step: 'plesk_validation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Services/Hosting/Provisioning/DeprovisionService.php
Normal file
86
app/Services/Hosting/Provisioning/DeprovisionService.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Provisioning;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\VmActivityLog;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class DeprovisionService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProvisioningService $provisioning,
|
||||||
|
private readonly ProxmoxClient $proxmox,
|
||||||
|
private readonly VmidReservationService $vmidReservation,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endgültige Löschung (WHMCS Terminate / Kunde nicht verlängert).
|
||||||
|
*/
|
||||||
|
public function terminatePermanently(Customer $customer, ?User $actor = null): void
|
||||||
|
{
|
||||||
|
Log::info('Permanent deprovision started', ['customer_id' => $customer->id]);
|
||||||
|
|
||||||
|
$this->deleteBackups($customer);
|
||||||
|
|
||||||
|
$customerId = $customer->id;
|
||||||
|
$vmid = $customer->vmid;
|
||||||
|
|
||||||
|
if ($actor) {
|
||||||
|
VmActivityLog::query()->create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'user_id' => $actor->id,
|
||||||
|
'action' => 'terminate.permanent',
|
||||||
|
'status' => 'success',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->provisioning->deprovision($customer);
|
||||||
|
|
||||||
|
if ($vmid) {
|
||||||
|
$this->vmidReservation->scheduleRelease((int) $vmid, $customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer->devices()->delete();
|
||||||
|
$customer->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VM entfernen, Kundeneintrag bleibt (Support-Fall).
|
||||||
|
*/
|
||||||
|
public function removeVmOnly(Customer $customer, ?User $actor = null): void
|
||||||
|
{
|
||||||
|
$this->provisioning->deprovision($customer);
|
||||||
|
|
||||||
|
if ($customer->vmid) {
|
||||||
|
$this->vmidReservation->scheduleRelease((int) $customer->vmid, $customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'provisioning_step' => 'deprovisioned',
|
||||||
|
'vmid' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteBackups(Customer $customer): void
|
||||||
|
{
|
||||||
|
if (! $customer->vmid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! config('hosting.backups.enabled', false)) {
|
||||||
|
Log::info('Backup deletion skipped (PBS not enabled)', ['vmid' => $customer->vmid]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PBS-Integration folgt in Phase 2, wenn API-Rechte auf inett-PBS vorhanden
|
||||||
|
Log::warning('PBS backup purge pending implementation', [
|
||||||
|
'vmid' => $customer->vmid,
|
||||||
|
'storage' => config('hosting.backups.pbs_storage'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Services/Hosting/Provisioning/IpAddressAllocator.php
Normal file
162
app/Services/Hosting/Provisioning/IpAddressAllocator.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Provisioning;
|
||||||
|
|
||||||
|
use App\Enums\IpPoolType;
|
||||||
|
use App\Exceptions\Hosting\ProvisioningException;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\IpPool;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IpAddressAllocator
|
||||||
|
{
|
||||||
|
public function allocateFromPool(?IpPool $pool = null, ?string $preferred = null): string
|
||||||
|
{
|
||||||
|
$pool ??= $this->defaultPrivatePool();
|
||||||
|
|
||||||
|
return $this->allocate($pool, $preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allocatePublicIp(?IpPool $pool = null, ?string $preferred = null): string
|
||||||
|
{
|
||||||
|
$pool ??= IpPool::query()
|
||||||
|
->where('type', IpPoolType::Public)
|
||||||
|
->where('is_active', true)
|
||||||
|
->first() ?? $this->bootstrapPublicPool();
|
||||||
|
|
||||||
|
if ($pool->type !== IpPoolType::Public) {
|
||||||
|
throw new ProvisioningException('Selected pool is not a public IP pool.', step: 'ip_allocation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->allocate($pool, $preferred, 'public_ip');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allocate(IpPool $pool, ?string $preferred = null, string $column = 'ip_address'): string
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($pool, $preferred, $column) {
|
||||||
|
IpPool::query()->whereKey($pool->id)->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($preferred !== null) {
|
||||||
|
if (! $pool->containsIp($preferred)) {
|
||||||
|
throw new ProvisioningException(
|
||||||
|
"IP {$preferred} is outside pool {$pool->name}.",
|
||||||
|
step: 'ip_allocation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isIpUsed($preferred, $pool, $column)) {
|
||||||
|
throw new ProvisioningException(
|
||||||
|
"IP address {$preferred} is already in use.",
|
||||||
|
step: 'ip_allocation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = ip2long($pool->start_ip);
|
||||||
|
$end = ip2long($pool->end_ip);
|
||||||
|
|
||||||
|
if ($start === false || $end === false || $start > $end) {
|
||||||
|
throw new ProvisioningException('Invalid IP pool range.', step: 'ip_allocation');
|
||||||
|
}
|
||||||
|
|
||||||
|
$used = $this->usedIpsInPool($pool, $column);
|
||||||
|
|
||||||
|
for ($long = $start; $long <= $end; $long++) {
|
||||||
|
if (! isset($used[$long])) {
|
||||||
|
return long2ip($long);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProvisioningException("No free IPs in pool {$pool->name}.", step: 'ip_allocation');
|
||||||
|
}, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultPrivatePool(): IpPool
|
||||||
|
{
|
||||||
|
$pool = IpPool::query()
|
||||||
|
->where('type', IpPoolType::Private)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($pool) {
|
||||||
|
return $pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->bootstrapPoolFromConfig(IpPoolType::Private);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bootstrapPublicPool(): IpPool
|
||||||
|
{
|
||||||
|
return IpPool::query()->firstOrCreate(
|
||||||
|
['name' => 'Öffentlich 185.45.149.x', 'type' => IpPoolType::Public],
|
||||||
|
[
|
||||||
|
'start_ip' => config('hosting.public_network.ip_pool_start'),
|
||||||
|
'end_ip' => config('hosting.public_network.ip_pool_end'),
|
||||||
|
'gateway' => config('hosting.public_network.gateway'),
|
||||||
|
'cidr' => config('hosting.public_network.cidr'),
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bootstrapPoolFromConfig(IpPoolType $type): IpPool
|
||||||
|
{
|
||||||
|
return IpPool::query()->firstOrCreate(
|
||||||
|
['name' => 'Privat 10.32.0.0/24', 'type' => IpPoolType::Private],
|
||||||
|
[
|
||||||
|
'start_ip' => config('hosting.network.ip_pool_start'),
|
||||||
|
'end_ip' => config('hosting.network.ip_pool_end'),
|
||||||
|
'gateway' => config('hosting.network.gateway'),
|
||||||
|
'cidr' => config('hosting.network.cidr'),
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function usedIpsInPool(IpPool $pool, string $column): array
|
||||||
|
{
|
||||||
|
$query = Customer::query()
|
||||||
|
->where('ip_pool_id', $pool->id)
|
||||||
|
->whereIn('status', ['pending', 'active']);
|
||||||
|
|
||||||
|
if ($column === 'public_ip') {
|
||||||
|
return $query->whereNotNull('public_ip')
|
||||||
|
->pluck('public_ip')
|
||||||
|
->merge(
|
||||||
|
Customer::query()
|
||||||
|
->whereIn('status', ['pending', 'active'])
|
||||||
|
->whereNotNull('public_ip')
|
||||||
|
->pluck('public_ip')
|
||||||
|
)
|
||||||
|
->unique()
|
||||||
|
->map(fn (string $ip) => ip2long($ip))
|
||||||
|
->filter()
|
||||||
|
->flip()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->whereNotNull('ip_address')
|
||||||
|
->pluck('ip_address')
|
||||||
|
->map(fn (string $ip) => ip2long($ip))
|
||||||
|
->filter()
|
||||||
|
->flip()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isIpUsed(string $ip, IpPool $pool, string $column): bool
|
||||||
|
{
|
||||||
|
$check = Customer::query()
|
||||||
|
->whereIn('status', ['pending', 'active']);
|
||||||
|
|
||||||
|
if ($column === 'public_ip') {
|
||||||
|
return (clone $check)->where('public_ip', $ip)->exists()
|
||||||
|
|| (clone $check)->where('ip_address', $ip)->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (clone $check)->where('ip_address', $ip)->exists()
|
||||||
|
|| (clone $check)->where('public_ip', $ip)->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Services/Hosting/Provisioning/ProvisioningRollback.php
Normal file
69
app/Services/Hosting/Provisioning/ProvisioningRollback.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Provisioning;
|
||||||
|
|
||||||
|
use App\Services\Hosting\Plesk\PleskClient;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use App\Services\Hosting\Traefik\TraefikGenerator;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ProvisioningRollback
|
||||||
|
{
|
||||||
|
private array $completed = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProxmoxClient $proxmox,
|
||||||
|
private readonly PleskClient $plesk,
|
||||||
|
private readonly TraefikGenerator $traefik,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function mark(string $step, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->completed[] = ['step' => $step, 'context' => $context];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(): void
|
||||||
|
{
|
||||||
|
Log::warning('Provisioning rollback started', ['steps' => count($this->completed)]);
|
||||||
|
|
||||||
|
foreach (array_reverse($this->completed) as $entry) {
|
||||||
|
try {
|
||||||
|
match ($entry['step']) {
|
||||||
|
'traefik_route' => $this->rollbackTraefik($entry['context']),
|
||||||
|
'plesk_dns' => $this->rollbackDns($entry['context']),
|
||||||
|
'proxmox_vm_started', 'proxmox_vm_created' => $this->rollbackVm($entry['context']),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Rollback step failed', [
|
||||||
|
'step' => $entry['step'],
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rollbackTraefik(array $context): void
|
||||||
|
{
|
||||||
|
if (! empty($context['domain'])) {
|
||||||
|
$this->traefik->removeCustomerRoute($context['domain']);
|
||||||
|
Log::info('Rollback: Traefik route removed', $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rollbackDns(array $context): void
|
||||||
|
{
|
||||||
|
if (! empty($context['domain']) && isset($context['subdomain'])) {
|
||||||
|
$this->plesk->deleteARecord($context['domain'], $context['subdomain']);
|
||||||
|
Log::info('Rollback: Plesk DNS removed', $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rollbackVm(array $context): void
|
||||||
|
{
|
||||||
|
if (! empty($context['vmid'])) {
|
||||||
|
$this->proxmox->deleteVM((int) $context['vmid']);
|
||||||
|
Log::info('Rollback: Proxmox VM deleted', $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
app/Services/Hosting/Provisioning/ProvisioningService.php
Normal file
262
app/Services/Hosting/Provisioning/ProvisioningService.php
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Provisioning;
|
||||||
|
|
||||||
|
use App\Exceptions\Hosting\ProvisioningException;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\IpPool;
|
||||||
|
use App\Models\VmTemplate;
|
||||||
|
use App\Services\Hosting\DTO\CustomerProvisionData;
|
||||||
|
use App\Services\Hosting\Plesk\PleskClient;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use App\Services\Hosting\Traefik\TraefikGenerator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ProvisioningService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProxmoxClient $proxmox,
|
||||||
|
private readonly PleskClient $plesk,
|
||||||
|
private readonly TraefikGenerator $traefik,
|
||||||
|
private readonly IpAddressAllocator $ipAllocator,
|
||||||
|
private readonly VmidReservationService $vmidReservation,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provision(Customer $customer, CustomerProvisionData $data): Customer
|
||||||
|
{
|
||||||
|
$rollback = new ProvisioningRollback($this->proxmox, $this->plesk, $this->traefik);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($customer, $data, $rollback) {
|
||||||
|
$this->updateStep($customer, 'reserving_vmid', 'pending');
|
||||||
|
|
||||||
|
$vmid = $data->vmid ?? $customer->vmid ?? $this->reserveVmid($customer);
|
||||||
|
$customer->update(['vmid' => $vmid]);
|
||||||
|
$this->vmidReservation->activate($vmid, $customer);
|
||||||
|
|
||||||
|
$pool = $this->resolveIpPool($customer);
|
||||||
|
$gateway = $pool->gateway ?? config('hosting.network.gateway');
|
||||||
|
$cidr = $pool->cidr ?? (int) config('hosting.network.cidr');
|
||||||
|
|
||||||
|
$this->updateStep($customer, 'allocating_ip');
|
||||||
|
$ip = $data->ip ?? $this->ipAllocator->allocateFromPool($pool);
|
||||||
|
$customer->update(['ip_address' => $ip, 'ip_pool_id' => $pool->id]);
|
||||||
|
|
||||||
|
$publicIp = null;
|
||||||
|
if (! $data->behind_traefik) {
|
||||||
|
$publicIp = $data->public_ip ?? $this->ipAllocator->allocatePublicIp();
|
||||||
|
$customer->update(['public_ip' => $publicIp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ipConfig0 = $this->proxmox->buildIpConfig($ip, $gateway, $cidr);
|
||||||
|
$ipConfig1 = null;
|
||||||
|
if ($publicIp) {
|
||||||
|
$publicPool = IpPool::query()->where('type', 'public')->where('is_active', true)->first();
|
||||||
|
$ipConfig1 = $this->proxmox->buildIpConfig(
|
||||||
|
$publicIp,
|
||||||
|
$publicPool?->gateway,
|
||||||
|
$publicPool?->cidr ?? 32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$vmName = $this->vmName($data->customer_name, $vmid);
|
||||||
|
$this->updateStep($customer, 'creating_vm');
|
||||||
|
$templateVmid = $this->resolveTemplateVmid($customer);
|
||||||
|
$this->proxmox->createVM(
|
||||||
|
vmid: $vmid,
|
||||||
|
name: $vmName,
|
||||||
|
cpu: $data->cpu,
|
||||||
|
ramMb: $data->ram,
|
||||||
|
diskGb: $data->disk,
|
||||||
|
ipConfig: $ipConfig0,
|
||||||
|
templateVmid: $templateVmid,
|
||||||
|
);
|
||||||
|
if ($ipConfig1) {
|
||||||
|
$this->proxmox->setCloudInitIps($vmid, $ipConfig0, $ipConfig1, $vmName);
|
||||||
|
}
|
||||||
|
$rollback->mark('proxmox_vm_created', ['vmid' => $vmid]);
|
||||||
|
|
||||||
|
$customer->load('devices');
|
||||||
|
if ($customer->devices->isNotEmpty()) {
|
||||||
|
$this->updateStep($customer, 'applying_devices');
|
||||||
|
$this->proxmox->applyDevices($vmid, $customer->devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateStep($customer, 'starting_vm');
|
||||||
|
$this->proxmox->startVM($vmid);
|
||||||
|
$rollback->mark('proxmox_vm_started', ['vmid' => $vmid]);
|
||||||
|
|
||||||
|
if ($customer->attached_iso) {
|
||||||
|
$this->updateStep($customer, 'mounting_iso');
|
||||||
|
$this->proxmox->mountIso($vmid, $customer->attached_iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data->requiresTraefik()) {
|
||||||
|
$subdomain = $data->subdomain();
|
||||||
|
$this->updateStep($customer, 'creating_dns');
|
||||||
|
$this->plesk->createARecord($data->domain, $subdomain, $ip);
|
||||||
|
$rollback->mark('plesk_dns', [
|
||||||
|
'domain' => $data->domain,
|
||||||
|
'subdomain' => $subdomain,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->updateStep($customer, 'configuring_traefik');
|
||||||
|
$this->traefik->addCustomerRoute($data->domain, $ip);
|
||||||
|
$rollback->mark('traefik_route', ['domain' => $data->domain]);
|
||||||
|
|
||||||
|
$this->updateStep($customer, 'reloading_traefik');
|
||||||
|
$this->traefik->reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$live = $this->proxmox->normalizeLiveStatus($this->proxmox->getVMStatus($vmid));
|
||||||
|
$customer->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'provisioning_step' => 'completed',
|
||||||
|
'error_message' => null,
|
||||||
|
'proxmox_status' => $live['status'],
|
||||||
|
'proxmox_uptime' => $live['uptime'],
|
||||||
|
'proxmox_status_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$customer->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'provisioning_step' => 'completed',
|
||||||
|
'error_message' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Provisioning completed', [
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'vmid' => $vmid,
|
||||||
|
'domain' => $data->domain,
|
||||||
|
'ip' => $ip,
|
||||||
|
'public_ip' => $publicIp,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $customer->fresh();
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Provisioning failed', [
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'step' => $customer->provisioning_step,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rollback->execute();
|
||||||
|
|
||||||
|
$customer->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'error_message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($e instanceof ProvisioningException) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProvisioningException(
|
||||||
|
$e->getMessage(),
|
||||||
|
step: $customer->provisioning_step,
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deprovision(Customer $customer): void
|
||||||
|
{
|
||||||
|
if ($customer->domain && $customer->behind_traefik) {
|
||||||
|
try {
|
||||||
|
$this->traefik->removeCustomerRoute($customer->domain);
|
||||||
|
$this->traefik->reload();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Deprovision: Traefik cleanup failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = CustomerProvisionData::fromCustomer($customer);
|
||||||
|
$this->plesk->deleteARecord($customer->domain, $data->subdomain());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Deprovision: DNS cleanup failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer->vmid) {
|
||||||
|
$this->proxmox->deleteVM((int) $customer->vmid);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer->update([
|
||||||
|
'status' => 'failed',
|
||||||
|
'provisioning_step' => 'deprovisioned',
|
||||||
|
'error_message' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveIpPool(Customer $customer): IpPool
|
||||||
|
{
|
||||||
|
if ($customer->ip_pool_id) {
|
||||||
|
$pool = IpPool::query()->find($customer->ip_pool_id);
|
||||||
|
if ($pool && $pool->is_active) {
|
||||||
|
return $pool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return IpPool::query()
|
||||||
|
->where('type', 'private')
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('id')
|
||||||
|
->firstOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reserveVmid(Customer $customer): int
|
||||||
|
{
|
||||||
|
if ($customer->vmid) {
|
||||||
|
$vmid = (int) $customer->vmid;
|
||||||
|
if ($this->vmidReservation->isVmidBlocked($vmid)) {
|
||||||
|
return $vmid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$vmid = $this->vmidReservation->reserveForCustomer($customer);
|
||||||
|
|
||||||
|
if ($this->proxmox->vmExists($vmid)) {
|
||||||
|
throw new ProvisioningException(
|
||||||
|
"VMID {$vmid} already exists in Proxmox.",
|
||||||
|
step: 'reserving_vmid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $vmid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function vmName(string $customerName, int $vmid): string
|
||||||
|
{
|
||||||
|
$slug = preg_replace('/[^a-z0-9-]/', '-', strtolower($customerName)) ?: 'customer';
|
||||||
|
|
||||||
|
return substr("host-{$slug}-{$vmid}", 0, 63);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTemplateVmid(Customer $customer): ?int
|
||||||
|
{
|
||||||
|
if ($customer->provision_mode === 'empty') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fromDb = VmTemplate::query()->where('is_active', true)->orderBy('id')->value('proxmox_template_vmid');
|
||||||
|
$fromEnv = config('hosting.proxmox.template_vmid');
|
||||||
|
|
||||||
|
return $fromDb ? (int) $fromDb : ($fromEnv ? (int) $fromEnv : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateStep(Customer $customer, string $step, ?string $status = null): void
|
||||||
|
{
|
||||||
|
$payload = ['provisioning_step' => $step];
|
||||||
|
|
||||||
|
if ($status !== null) {
|
||||||
|
$payload['status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer->update($payload);
|
||||||
|
Log::info('Provisioning step', ['customer_id' => $customer->id, 'step' => $step]);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/Services/Hosting/Provisioning/VmidReservationService.php
Normal file
104
app/Services/Hosting/Provisioning/VmidReservationService.php
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Provisioning;
|
||||||
|
|
||||||
|
use App\Exceptions\Hosting\ProvisioningException;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\VmidReservation;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class VmidReservationService
|
||||||
|
{
|
||||||
|
public function reserveForCustomer(Customer $customer): int
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($customer) {
|
||||||
|
$vmid = $this->findNextAvailableVmid();
|
||||||
|
|
||||||
|
VmidReservation::query()->create([
|
||||||
|
'vmid' => $vmid,
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'status' => 'reserved',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $vmid;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activate(int $vmid, Customer $customer): void
|
||||||
|
{
|
||||||
|
VmidReservation::query()
|
||||||
|
->where('vmid', $vmid)
|
||||||
|
->where('customer_id', $customer->id)
|
||||||
|
->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'release_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scheduleRelease(int $vmid, ?Customer $customer = null): void
|
||||||
|
{
|
||||||
|
$hours = (int) config('hosting.vmid.release_after_hours', 48);
|
||||||
|
|
||||||
|
$query = VmidReservation::query()->where('vmid', $vmid);
|
||||||
|
|
||||||
|
if ($customer) {
|
||||||
|
$query->where('customer_id', $customer->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->update([
|
||||||
|
'status' => 'pending_release',
|
||||||
|
'release_at' => now()->addHours($hours),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseDue(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
$due = VmidReservation::query()
|
||||||
|
->where('status', 'pending_release')
|
||||||
|
->where('release_at', '<=', now())
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($due as $reservation) {
|
||||||
|
$reservation->update([
|
||||||
|
'status' => 'released',
|
||||||
|
'released_at' => now(),
|
||||||
|
]);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isVmidBlocked(int $vmid): bool
|
||||||
|
{
|
||||||
|
return VmidReservation::query()
|
||||||
|
->where('vmid', $vmid)
|
||||||
|
->whereIn('status', ['reserved', 'active', 'pending_release'])
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findNextAvailableVmid(): int
|
||||||
|
{
|
||||||
|
$start = (int) config('hosting.vmid.range_start', 2000);
|
||||||
|
$end = (int) config('hosting.vmid.range_end', 2999);
|
||||||
|
|
||||||
|
$blocked = VmidReservation::query()
|
||||||
|
->whereIn('status', ['reserved', 'active', 'pending_release'])
|
||||||
|
->pluck('vmid')
|
||||||
|
->flip()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
for ($vmid = $start; $vmid <= $end; $vmid++) {
|
||||||
|
if (! isset($blocked[$vmid])) {
|
||||||
|
return $vmid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProvisioningException(
|
||||||
|
"No free VMID in range {$start}-{$end}.",
|
||||||
|
step: 'reserving_vmid',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
591
app/Services/Hosting/Proxmox/ProxmoxClient.php
Normal file
591
app/Services/Hosting/Proxmox/ProxmoxClient.php
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Proxmox;
|
||||||
|
|
||||||
|
use App\Exceptions\Hosting\ProxmoxException;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ProxmoxClient
|
||||||
|
{
|
||||||
|
private ?PendingRequest $http = null;
|
||||||
|
|
||||||
|
private function http(): PendingRequest
|
||||||
|
{
|
||||||
|
if ($this->http !== null) {
|
||||||
|
return $this->http;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = config('hosting.proxmox.token');
|
||||||
|
if (empty($token)) {
|
||||||
|
throw new ProxmoxException('PROXMOX_TOKEN is not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$verify = filter_var(config('hosting.proxmox.verify_ssl'), FILTER_VALIDATE_BOOLEAN);
|
||||||
|
|
||||||
|
return $this->http = Http::baseUrl(rtrim(config('hosting.proxmox.url'), '/').'/api2/json')
|
||||||
|
->withHeaders([
|
||||||
|
'Authorization' => str_starts_with($token, 'PVEAPIToken=') ? $token : 'PVEAPIToken='.$token,
|
||||||
|
])
|
||||||
|
->acceptJson()
|
||||||
|
->timeout((int) config('hosting.proxmox.timeout', 120))
|
||||||
|
->when(! $verify, fn (PendingRequest $request) => $request->withoutVerifying());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNextVmid(): int
|
||||||
|
{
|
||||||
|
$response = $this->request('GET', '/cluster/nextid');
|
||||||
|
|
||||||
|
return (int) $response['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function vmExists(int $vmid): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->getVMStatus($vmid);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (ProxmoxException $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'does not exist') || $e->getCode() === 404) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createVM(
|
||||||
|
int $vmid,
|
||||||
|
string $name,
|
||||||
|
int $cpu,
|
||||||
|
int $ramMb,
|
||||||
|
int $diskGb,
|
||||||
|
string $ipConfig,
|
||||||
|
?int $templateVmid = null,
|
||||||
|
): void {
|
||||||
|
if ($this->vmExists($vmid)) {
|
||||||
|
throw new ProxmoxException("VM {$vmid} already exists.", step: 'proxmox_create');
|
||||||
|
}
|
||||||
|
|
||||||
|
$node = config('hosting.proxmox.node');
|
||||||
|
$storage = config('hosting.proxmox.storage');
|
||||||
|
$bridge = config('hosting.proxmox.bridge');
|
||||||
|
$templateVmid ??= config('hosting.proxmox.template_vmid') ? (int) config('hosting.proxmox.template_vmid') : null;
|
||||||
|
|
||||||
|
Log::info('Proxmox: creating VM', compact('vmid', 'name', 'cpu', 'ramMb', 'diskGb'));
|
||||||
|
|
||||||
|
if ($templateVmid) {
|
||||||
|
$this->cloneFromTemplateVmid((int) $templateVmid, $vmid, $name);
|
||||||
|
$this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb);
|
||||||
|
$this->configureCloudInit($vmid, $ipConfig, $name);
|
||||||
|
} else {
|
||||||
|
$this->createVmFromScratch($vmid, $name, $cpu, $ramMb, $diskGb, $ipConfig, $node, $storage, $bridge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCloudInitIps(int $vmid, string $ipconfig0, ?string $ipconfig1, string $name): void
|
||||||
|
{
|
||||||
|
$this->configureCloudInit($vmid, $ipconfig0, $name, $ipconfig1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function node(): string
|
||||||
|
{
|
||||||
|
return (string) config('hosting.proxmox.node');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startVM(int $vmid): void
|
||||||
|
{
|
||||||
|
$this->powerAction($vmid, 'start');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shutdownVM(int $vmid, int $timeout = 60): void
|
||||||
|
{
|
||||||
|
$this->powerAction($vmid, 'shutdown', ['timeout' => $timeout]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stopVM(int $vmid): void
|
||||||
|
{
|
||||||
|
$this->powerAction($vmid, 'stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rebootVM(int $vmid, int $timeout = 60): void
|
||||||
|
{
|
||||||
|
$this->powerAction($vmid, 'reboot', ['timeout' => $timeout]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetVM(int $vmid): void
|
||||||
|
{
|
||||||
|
$this->powerAction($vmid, 'reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function powerAction(int $vmid, string $action, array $params = []): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
Log::info('Proxmox: power action', ['vmid' => $vmid, 'action' => $action]);
|
||||||
|
$this->request('POST', "/nodes/{$node}/qemu/{$vmid}/status/{$action}", $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVmConfig(int $vmid): array
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/config");
|
||||||
|
|
||||||
|
return $response['data'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{volid: string, label: string, size: int}>
|
||||||
|
*/
|
||||||
|
public function listIsos(?string $storage = null): array
|
||||||
|
{
|
||||||
|
$storage ??= config('hosting.proxmox.iso_storage', 'local');
|
||||||
|
$node = $this->node();
|
||||||
|
|
||||||
|
$response = $this->request('GET', "/nodes/{$node}/storage/{$storage}/content", [
|
||||||
|
'content' => 'iso',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = $response['data'] ?? [];
|
||||||
|
|
||||||
|
return collect($items)
|
||||||
|
->filter(fn ($item) => is_array($item) && ($item['content'] ?? '') === 'iso')
|
||||||
|
->map(fn (array $item) => [
|
||||||
|
'volid' => (string) ($item['volid'] ?? ''),
|
||||||
|
'label' => (string) ($item['volid'] ?? $item['name'] ?? 'unknown'),
|
||||||
|
'size' => (int) ($item['size'] ?? 0),
|
||||||
|
])
|
||||||
|
->filter(fn (array $item) => $item['volid'] !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mountIso(int $vmid, string $isoVolid, ?string $device = null): void
|
||||||
|
{
|
||||||
|
$device ??= config('hosting.proxmox.iso_device', 'ide2');
|
||||||
|
$node = $this->node();
|
||||||
|
|
||||||
|
if (! str_contains($isoVolid, ':')) {
|
||||||
|
$storage = config('hosting.proxmox.iso_storage', 'local');
|
||||||
|
$isoVolid = "{$storage}:iso/{$isoVolid}";
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Proxmox: mounting ISO', ['vmid' => $vmid, 'iso' => $isoVolid, 'device' => $device]);
|
||||||
|
|
||||||
|
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
|
||||||
|
$device => "{$isoVolid},media=cdrom",
|
||||||
|
'boot' => "order={$device};scsi0",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unmountIso(int $vmid, ?string $device = null): void
|
||||||
|
{
|
||||||
|
$device ??= config('hosting.proxmox.iso_device', 'ide2');
|
||||||
|
$node = $this->node();
|
||||||
|
|
||||||
|
Log::info('Proxmox: unmounting ISO', ['vmid' => $vmid, 'device' => $device]);
|
||||||
|
|
||||||
|
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
|
||||||
|
'delete' => $device,
|
||||||
|
'boot' => 'order=scsi0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{port: string, ticket: string, cert: ?string, node: string, vmid: int}
|
||||||
|
*/
|
||||||
|
public function createVncProxy(int $vmid): array
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
|
||||||
|
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/vncproxy", [
|
||||||
|
'websocket' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = $response['data'] ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'port' => (string) ($data['port'] ?? ''),
|
||||||
|
'ticket' => (string) ($data['ticket'] ?? ''),
|
||||||
|
'cert' => isset($data['cert']) ? (string) $data['cert'] : null,
|
||||||
|
'node' => $node,
|
||||||
|
'vmid' => $vmid,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildVncWebSocketUrl(int $vmid, string $port, string $ticket): string
|
||||||
|
{
|
||||||
|
$base = rtrim(config('hosting.proxmox.console_ws_url') ?: config('hosting.proxmox.url'), '/');
|
||||||
|
$parsed = parse_url($base);
|
||||||
|
$scheme = ($parsed['scheme'] ?? 'https') === 'https' ? 'wss' : 'ws';
|
||||||
|
$host = $parsed['host'] ?? 'localhost';
|
||||||
|
$portNum = $parsed['port'] ?? (($parsed['scheme'] ?? 'https') === 'https' ? 8006 : 80);
|
||||||
|
$node = $this->node();
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'%s://%s:%s/api2/json/nodes/%s/qemu/%d/vncwebsocket?port=%s&vncticket=%s',
|
||||||
|
$scheme,
|
||||||
|
$host,
|
||||||
|
$portNum,
|
||||||
|
$node,
|
||||||
|
$vmid,
|
||||||
|
urlencode($port),
|
||||||
|
urlencode($ticket),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeLiveStatus(array $status): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => (string) ($status['status'] ?? 'unknown'),
|
||||||
|
'uptime' => (int) ($status['uptime'] ?? 0),
|
||||||
|
'cpu' => (float) ($status['cpu'] ?? 0),
|
||||||
|
'mem' => (int) ($status['mem'] ?? 0),
|
||||||
|
'maxmem' => (int) ($status['maxmem'] ?? 0),
|
||||||
|
'disk' => (int) ($status['disk'] ?? 0),
|
||||||
|
'maxdisk' => (int) ($status['maxdisk'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function waitForTask(string $upid, int $maxSeconds = 600): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$deadline = time() + $maxSeconds;
|
||||||
|
|
||||||
|
while (time() < $deadline) {
|
||||||
|
$response = $this->request('GET', '/nodes/'.$node.'/tasks/'.rawurlencode($upid).'/status');
|
||||||
|
$status = $response['data']['status'] ?? '';
|
||||||
|
if ($status === 'stopped') {
|
||||||
|
$exit = $response['data']['exitstatus'] ?? 'OK';
|
||||||
|
if ($exit !== 'OK') {
|
||||||
|
throw new ProxmoxException('Proxmox task failed: '.$exit, step: 'proxmox_task');
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProxmoxException('Proxmox task timeout: '.$upid, step: 'proxmox_task');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createSnapshot(int $vmid, string $name): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot", [
|
||||||
|
'snapname' => $name,
|
||||||
|
]);
|
||||||
|
if (! empty($response['data'])) {
|
||||||
|
$this->waitForTask($response['data']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollbackSnapshot(int $vmid, string $name): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$response = $this->request('POST', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}/rollback");
|
||||||
|
if (! empty($response['data'])) {
|
||||||
|
$this->waitForTask($response['data']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteSnapshot(int $vmid, string $name): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}/snapshot/{$name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{name: string, description: string}>
|
||||||
|
*/
|
||||||
|
public function listSnapshots(int $vmid): array
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/snapshot");
|
||||||
|
|
||||||
|
return collect($response['data'] ?? [])
|
||||||
|
->filter(fn ($item) => is_array($item))
|
||||||
|
->map(fn (array $item) => [
|
||||||
|
'name' => (string) ($item['name'] ?? ''),
|
||||||
|
'description' => (string) ($item['description'] ?? ''),
|
||||||
|
])
|
||||||
|
->filter(fn ($item) => $item['name'] !== '')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startBackup(int $vmid, string $storage): string
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$response = $this->request('POST', "/nodes/{$node}/vzdump", [
|
||||||
|
'vmid' => $vmid,
|
||||||
|
'storage' => $storage,
|
||||||
|
'mode' => 'snapshot',
|
||||||
|
'compress' => 'zstd',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (string) ($response['data'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFirewallRules(int $vmid): array
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/firewall/rules");
|
||||||
|
|
||||||
|
return $response['data'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFirewallRules(int $vmid, array $rules): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
// Replace all rules: delete existing then add - simplified: enable firewall + pos rules via PUT pos
|
||||||
|
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/firewall/options", ['enable' => 1]);
|
||||||
|
foreach ($rules as $pos => $rule) {
|
||||||
|
$this->request('POST', "/nodes/{$node}/qemu/{$vmid}/firewall/rules", array_merge($rule, ['pos' => $pos]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadIsoToStorage(string $storage, string $filename, string $localPath): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$client = $this->http();
|
||||||
|
$response = $client->attach(
|
||||||
|
'content',
|
||||||
|
file_get_contents($localPath),
|
||||||
|
$filename
|
||||||
|
)->post("/nodes/{$node}/storage/{$storage}/upload", [
|
||||||
|
'content' => 'iso',
|
||||||
|
'filename' => $filename,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
throw new ProxmoxException('ISO upload failed: '.$response->body(), step: 'proxmox_upload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteStorageFile(string $storage, string $volid): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$this->request('DELETE', "/nodes/{$node}/storage/{$storage}/content/".urlencode($volid));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cloneFromTemplateVmid(int $templateVmid, int $newVmid, string $name): void
|
||||||
|
{
|
||||||
|
$node = $this->node();
|
||||||
|
$response = $this->request('POST', "/nodes/{$node}/qemu/{$templateVmid}/clone", [
|
||||||
|
'newid' => $newVmid,
|
||||||
|
'name' => $name,
|
||||||
|
'full' => 1,
|
||||||
|
'target' => $node,
|
||||||
|
]);
|
||||||
|
if (! empty($response['data'])) {
|
||||||
|
$this->waitForTask($response['data']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteVM(int $vmid): void
|
||||||
|
{
|
||||||
|
if (! $this->vmExists($vmid)) {
|
||||||
|
Log::warning('Proxmox: VM already absent, skipping delete', ['vmid' => $vmid]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$status = $this->getVMStatus($vmid);
|
||||||
|
if (($status['status'] ?? '') === 'running') {
|
||||||
|
$this->stopVM($vmid);
|
||||||
|
$this->waitUntilStopped($vmid);
|
||||||
|
}
|
||||||
|
} catch (ProxmoxException) {
|
||||||
|
// continue with delete attempt
|
||||||
|
}
|
||||||
|
|
||||||
|
$node = config('hosting.proxmox.node');
|
||||||
|
Log::info('Proxmox: deleting VM', ['vmid' => $vmid]);
|
||||||
|
$this->request('DELETE', "/nodes/{$node}/qemu/{$vmid}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getVMStatus(int $vmid): array
|
||||||
|
{
|
||||||
|
$node = config('hosting.proxmox.node');
|
||||||
|
$response = $this->request('GET', "/nodes/{$node}/qemu/{$vmid}/status/current");
|
||||||
|
|
||||||
|
return $response['data'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildIpConfig(string $ip, ?string $gateway = null, ?int $cidr = null): string
|
||||||
|
{
|
||||||
|
$cidr ??= (int) config('hosting.network.cidr');
|
||||||
|
$gateway ??= config('hosting.network.gateway');
|
||||||
|
|
||||||
|
return "ip={$ip}/{$cidr},gw={$gateway}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, \App\Models\VmDevice> $devices
|
||||||
|
*/
|
||||||
|
public function applyDevices(int $vmid, iterable $devices): void
|
||||||
|
{
|
||||||
|
$node = config('hosting.proxmox.node');
|
||||||
|
$storage = config('hosting.proxmox.storage');
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
foreach ($devices as $device) {
|
||||||
|
$config = $device->config ?? [];
|
||||||
|
$slot = $device->slot;
|
||||||
|
|
||||||
|
match ($device->type) {
|
||||||
|
\App\Models\VmDevice::TYPE_DISK => $params[$slot ?? 'scsi1'] = sprintf(
|
||||||
|
'%s:%d',
|
||||||
|
$config['storage'] ?? $storage,
|
||||||
|
(int) ($config['size_gb'] ?? 10),
|
||||||
|
),
|
||||||
|
\App\Models\VmDevice::TYPE_NETWORK => $params[$slot ?? 'net1'] = sprintf(
|
||||||
|
'%s,bridge=%s',
|
||||||
|
$config['model'] ?? 'virtio',
|
||||||
|
$config['bridge'] ?? config('hosting.proxmox.bridge'),
|
||||||
|
),
|
||||||
|
\App\Models\VmDevice::TYPE_USB => $params[$slot ?? 'usb0'] = $config['host_id'] ?? 'host=0',
|
||||||
|
\App\Models\VmDevice::TYPE_PCI => $params[$slot ?? 'hostpci0'] = $config['address'] ?? '',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = array_filter($params, fn ($v) => $v !== '' && $v !== null);
|
||||||
|
|
||||||
|
if ($params === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Proxmox: applying VM devices', ['vmid' => $vmid, 'devices' => array_keys($params)]);
|
||||||
|
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateVmResources(int $vmid, int $cpu, int $ramMb, int $diskGb): void
|
||||||
|
{
|
||||||
|
$this->resizeVmIfNeeded($vmid, $cpu, $ramMb, $diskGb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resizeVmIfNeeded(int $vmid, int $cpu, int $ramMb, int $diskGb): void
|
||||||
|
{
|
||||||
|
$node = config('hosting.proxmox.node');
|
||||||
|
|
||||||
|
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", [
|
||||||
|
'cores' => $cpu,
|
||||||
|
'memory' => $ramMb,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Disk resize depends on storage layout; best-effort grow on scsi0
|
||||||
|
try {
|
||||||
|
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/resize", [
|
||||||
|
'disk' => 'scsi0',
|
||||||
|
'size' => $diskGb.'G',
|
||||||
|
]);
|
||||||
|
} catch (ProxmoxException $e) {
|
||||||
|
Log::warning('Proxmox: disk resize skipped', ['vmid' => $vmid, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configureCloudInit(int $vmid, string $ipConfig, string $name, ?string $ipConfig1 = null): void
|
||||||
|
{
|
||||||
|
$node = config('hosting.proxmox.node');
|
||||||
|
$bridge = config('hosting.proxmox.bridge');
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'name' => $name,
|
||||||
|
'ipconfig0' => $ipConfig,
|
||||||
|
'net0' => "virtio,bridge={$bridge}",
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($ipConfig1) {
|
||||||
|
$publicBridge = config('hosting.proxmox.public_bridge', 'vmbr1');
|
||||||
|
$params['ipconfig1'] = $ipConfig1;
|
||||||
|
$params['net1'] = "virtio,bridge={$publicBridge}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->request('PUT', "/nodes/{$node}/qemu/{$vmid}/config", $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createVmFromScratch(
|
||||||
|
int $vmid,
|
||||||
|
string $name,
|
||||||
|
int $cpu,
|
||||||
|
int $ramMb,
|
||||||
|
int $diskGb,
|
||||||
|
string $ipConfig,
|
||||||
|
string $node,
|
||||||
|
string $storage,
|
||||||
|
string $bridge,
|
||||||
|
): void {
|
||||||
|
$this->request('POST', "/nodes/{$node}/qemu", [
|
||||||
|
'vmid' => $vmid,
|
||||||
|
'name' => $name,
|
||||||
|
'cores' => $cpu,
|
||||||
|
'memory' => $ramMb,
|
||||||
|
'ostype' => 'l26',
|
||||||
|
'scsihw' => 'virtio-scsi-pci',
|
||||||
|
'scsi0' => "{$storage}:{$diskGb}",
|
||||||
|
'ide2' => "{$storage}:cloudinit",
|
||||||
|
'boot' => 'order=scsi0',
|
||||||
|
'net0' => "virtio,bridge={$bridge}",
|
||||||
|
'ipconfig0' => $ipConfig,
|
||||||
|
'agent' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitUntilStopped(int $vmid, int $maxSeconds = 60): void
|
||||||
|
{
|
||||||
|
$deadline = time() + $maxSeconds;
|
||||||
|
|
||||||
|
while (time() < $deadline) {
|
||||||
|
$status = $this->getVMStatus($vmid);
|
||||||
|
if (($status['status'] ?? '') !== 'running') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sleep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ProxmoxException("Timeout waiting for VM {$vmid} to stop.", step: 'proxmox_stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $method, string $uri, array $data = []): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$client = $this->http();
|
||||||
|
$response = match (strtoupper($method)) {
|
||||||
|
'GET' => $client->get($uri, $data),
|
||||||
|
'POST' => $client->asForm()->post($uri, $data),
|
||||||
|
'PUT' => $client->asForm()->put($uri, $data),
|
||||||
|
'DELETE' => $client->delete($uri, $data),
|
||||||
|
default => throw new ProxmoxException("Unsupported HTTP method: {$method}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$message = $response->json('errors') ?? $response->json('message') ?? $response->body();
|
||||||
|
throw new ProxmoxException(
|
||||||
|
'Proxmox API error: '.(is_string($message) ? $message : json_encode($message)),
|
||||||
|
step: 'proxmox_api',
|
||||||
|
context: ['uri' => $uri, 'status' => $response->status()],
|
||||||
|
code: $response->status(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->json() ?? [];
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
throw new ProxmoxException(
|
||||||
|
'Proxmox connection timeout or unreachable: '.$e->getMessage(),
|
||||||
|
step: 'proxmox_connection',
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
} catch (RequestException $e) {
|
||||||
|
throw new ProxmoxException(
|
||||||
|
'Proxmox HTTP error: '.$e->getMessage(),
|
||||||
|
step: 'proxmox_http',
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
app/Services/Hosting/Proxmox/VmManagementService.php
Normal file
192
app/Services/Hosting/Proxmox/VmManagementService.php
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Proxmox;
|
||||||
|
|
||||||
|
use App\Enums\VmPowerAction;
|
||||||
|
use App\Exceptions\Hosting\ProxmoxException;
|
||||||
|
use App\Exceptions\Hosting\ProvisioningException;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\VmActivityLog;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class VmManagementService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProxmoxClient $proxmox,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function assertManageable(Customer $vm): void
|
||||||
|
{
|
||||||
|
if (! $vm->vmid) {
|
||||||
|
throw new ProvisioningException('VM ist noch nicht in Proxmox provisioniert.', step: 'vm_not_ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($vm->status !== 'active') {
|
||||||
|
throw new ProvisioningException('VM ist nicht aktiv (Status: '.$vm->status.').', step: 'vm_not_active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshLiveStatus(Customer $vm): array
|
||||||
|
{
|
||||||
|
$this->assertManageable($vm);
|
||||||
|
|
||||||
|
$raw = $this->proxmox->getVMStatus((int) $vm->vmid);
|
||||||
|
$normalized = $this->proxmox->normalizeLiveStatus($raw);
|
||||||
|
|
||||||
|
$vm->update([
|
||||||
|
'proxmox_status' => $normalized['status'],
|
||||||
|
'proxmox_uptime' => $normalized['uptime'],
|
||||||
|
'proxmox_status_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function power(Customer $vm, VmPowerAction $action, User $user): void
|
||||||
|
{
|
||||||
|
$this->assertManageable($vm);
|
||||||
|
|
||||||
|
$live = $this->refreshLiveStatus($vm);
|
||||||
|
$isRunning = ($live['status'] ?? '') === 'running';
|
||||||
|
|
||||||
|
if ($action->requiresRunning() && ! $isRunning) {
|
||||||
|
throw new ProxmoxException('VM läuft nicht – Aktion nicht möglich.', step: 'power_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action->requiresStopped() && $isRunning) {
|
||||||
|
throw new ProxmoxException('VM läuft bereits.', step: 'power_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
match ($action) {
|
||||||
|
VmPowerAction::Start => $this->proxmox->startVM((int) $vm->vmid),
|
||||||
|
VmPowerAction::Shutdown => $this->proxmox->shutdownVM((int) $vm->vmid),
|
||||||
|
VmPowerAction::Stop => $this->proxmox->stopVM((int) $vm->vmid),
|
||||||
|
VmPowerAction::Reboot => $this->proxmox->rebootVM((int) $vm->vmid),
|
||||||
|
VmPowerAction::Reset => $this->proxmox->resetVM((int) $vm->vmid),
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->log($vm, $user, 'power.'.$action->value, 'success');
|
||||||
|
$this->refreshLiveStatus($vm);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->log($vm, $user, 'power.'.$action->value, 'failed', $e->getMessage());
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mountIso(Customer $vm, string $isoVolid, User $user): void
|
||||||
|
{
|
||||||
|
$this->assertManageable($vm);
|
||||||
|
|
||||||
|
$available = collect($this->proxmox->listIsos())->pluck('volid');
|
||||||
|
if (! $available->contains($isoVolid)) {
|
||||||
|
throw new ProxmoxException('ISO nicht im Storage gefunden.', step: 'iso_invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->proxmox->mountIso((int) $vm->vmid, $isoVolid);
|
||||||
|
$vm->update(['attached_iso' => $isoVolid]);
|
||||||
|
$this->log($vm, $user, 'iso.mount', 'success', null, ['iso' => $isoVolid]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->log($vm, $user, 'iso.mount', 'failed', $e->getMessage());
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unmountIso(Customer $vm, User $user): void
|
||||||
|
{
|
||||||
|
$this->assertManageable($vm);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->proxmox->unmountIso((int) $vm->vmid);
|
||||||
|
$vm->update(['attached_iso' => null]);
|
||||||
|
$this->log($vm, $user, 'iso.unmount', 'success');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->log($vm, $user, 'iso.unmount', 'failed', $e->getMessage());
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{token: string, ws_url: string, vmid: int, node: string, expires_at: int}
|
||||||
|
*/
|
||||||
|
public function createConsoleSession(Customer $vm, User $user): array
|
||||||
|
{
|
||||||
|
$this->assertManageable($vm);
|
||||||
|
|
||||||
|
$live = $this->refreshLiveStatus($vm);
|
||||||
|
if (($live['status'] ?? '') !== 'running') {
|
||||||
|
throw new ProxmoxException('Konsole nur bei laufender VM verfügbar. Bitte VM starten.', step: 'console_not_running');
|
||||||
|
}
|
||||||
|
|
||||||
|
$proxy = $this->proxmox->createVncProxy((int) $vm->vmid);
|
||||||
|
|
||||||
|
if ($proxy['port'] === '' || $proxy['ticket'] === '') {
|
||||||
|
throw new ProxmoxException('VNC-Proxy konnte nicht erstellt werden.', step: 'console_proxy');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = Str::random(64);
|
||||||
|
$proxmoxWsUrl = $this->proxmox->buildVncWebSocketUrl((int) $vm->vmid, $proxy['port'], $proxy['ticket']);
|
||||||
|
$expiresAt = now()->addMinutes(5)->timestamp;
|
||||||
|
|
||||||
|
$wsUrl = $proxmoxWsUrl;
|
||||||
|
if (config('hosting.console.proxy_enabled') && config('hosting.console.proxy_ws_url')) {
|
||||||
|
$wsUrl = rtrim(config('hosting.console.proxy_ws_url'), '/').'/'.$token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache::put($this->consoleCacheKey($token), [
|
||||||
|
'customer_id' => $vm->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ws_url' => $proxmoxWsUrl,
|
||||||
|
'ticket' => $proxy['ticket'],
|
||||||
|
'port' => $proxy['port'],
|
||||||
|
'vmid' => $vm->vmid,
|
||||||
|
'node' => $proxy['node'],
|
||||||
|
'cert' => $proxy['cert'],
|
||||||
|
], 300);
|
||||||
|
|
||||||
|
$this->log($vm, $user, 'console.open', 'success');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'token' => $token,
|
||||||
|
'ws_url' => $wsUrl,
|
||||||
|
'vmid' => (int) $vm->vmid,
|
||||||
|
'node' => $proxy['node'],
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConsoleSession(string $token, User $user): array
|
||||||
|
{
|
||||||
|
$data = Cache::get($this->consoleCacheKey($token));
|
||||||
|
|
||||||
|
if (! $data) {
|
||||||
|
throw new ProxmoxException('Konsole-Session abgelaufen oder ungültig.', step: 'console_expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['user_id'] !== $user->id && ! $user->isAdmin()) {
|
||||||
|
throw new ProxmoxException('Kein Zugriff auf diese Konsole.', step: 'console_forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consoleCacheKey(string $token): string
|
||||||
|
{
|
||||||
|
return 'vm_console:'.$token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(Customer $vm, User $user, string $action, string $status, ?string $message = null, array $meta = []): void
|
||||||
|
{
|
||||||
|
VmActivityLog::query()->create([
|
||||||
|
'customer_id' => $vm->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'action' => $action,
|
||||||
|
'status' => $status,
|
||||||
|
'message' => $message,
|
||||||
|
'meta' => $meta ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Services/Hosting/Reinstall/ReinstallService.php
Normal file
40
app/Services/Hosting/Reinstall/ReinstallService.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Reinstall;
|
||||||
|
|
||||||
|
use App\Jobs\ProvisionCustomerJob;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use App\Services\Hosting\Snapshots\SnapshotService;
|
||||||
|
|
||||||
|
class ReinstallService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProxmoxClient $proxmox,
|
||||||
|
private readonly SnapshotService $snapshots,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function reinstall(Customer $vm, User $user): void
|
||||||
|
{
|
||||||
|
if (! $vm->vmid) {
|
||||||
|
throw new \RuntimeException('VM ist nicht provisioniert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->snapshots->autoBeforeDestructive($vm, $user, 'reinstall');
|
||||||
|
|
||||||
|
$vmid = (int) $vm->vmid;
|
||||||
|
$this->proxmox->stopVM($vmid);
|
||||||
|
$this->proxmox->deleteVM($vmid);
|
||||||
|
|
||||||
|
$vm->update([
|
||||||
|
'status' => 'pending',
|
||||||
|
'provisioning_step' => 'queued',
|
||||||
|
'proxmox_status' => null,
|
||||||
|
'proxmox_uptime' => null,
|
||||||
|
'error_message' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ProvisionCustomerJob::dispatch($vm->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Services/Hosting/Snapshots/SnapshotService.php
Normal file
70
app/Services/Hosting/Snapshots/SnapshotService.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Snapshots;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\VmSnapshot;
|
||||||
|
use App\Services\Hosting\Proxmox\ProxmoxClient;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SnapshotService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProxmoxClient $proxmox) {}
|
||||||
|
|
||||||
|
public function create(Customer $vm, User $user, bool $auto = false, ?string $label = null): VmSnapshot
|
||||||
|
{
|
||||||
|
$name = 'snap-'.now()->format('Ymd-His').'-'.Str::lower(Str::random(4));
|
||||||
|
$this->proxmox->createSnapshot((int) $vm->vmid, $name);
|
||||||
|
|
||||||
|
$hours = (int) (config('hosting.snapshots.retention_hours') ?? 48);
|
||||||
|
|
||||||
|
return VmSnapshot::query()->create([
|
||||||
|
'customer_id' => $vm->id,
|
||||||
|
'name' => $label ?? $name,
|
||||||
|
'proxmox_snapshot_id' => $name,
|
||||||
|
'auto_created' => $auto,
|
||||||
|
'expires_at' => now()->addHours($hours),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function autoBeforeDestructive(Customer $vm, User $user, string $reason): void
|
||||||
|
{
|
||||||
|
if (! config('hosting.snapshots.auto_before_destructive', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->create($vm, $user, true, "auto-{$reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollback(Customer $vm, VmSnapshot $snapshot): void
|
||||||
|
{
|
||||||
|
$this->proxmox->rollbackSnapshot((int) $vm->vmid, $snapshot->proxmox_snapshot_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Customer $vm, VmSnapshot $snapshot): void
|
||||||
|
{
|
||||||
|
$this->proxmox->deleteSnapshot((int) $vm->vmid, $snapshot->proxmox_snapshot_id);
|
||||||
|
$snapshot->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pruneExpired(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
$expired = VmSnapshot::query()->where('expires_at', '<=', now())->with('vm')->get();
|
||||||
|
|
||||||
|
foreach ($expired as $snapshot) {
|
||||||
|
if ($snapshot->vm?->vmid) {
|
||||||
|
try {
|
||||||
|
$this->proxmox->deleteSnapshot((int) $snapshot->vm->vmid, $snapshot->proxmox_snapshot_id);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// snapshot may already be gone in Proxmox
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$snapshot->delete();
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
}
|
||||||
233
app/Services/Hosting/Traefik/TraefikGenerator.php
Normal file
233
app/Services/Hosting/Traefik/TraefikGenerator.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Hosting\Traefik;
|
||||||
|
|
||||||
|
use App\Exceptions\Hosting\TraefikException;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
use Symfony\Component\Yaml\Exception\ParseException;
|
||||||
|
|
||||||
|
class TraefikGenerator
|
||||||
|
{
|
||||||
|
private const MANAGED_PREFIX = 'customer-';
|
||||||
|
|
||||||
|
public function configPath(): string
|
||||||
|
{
|
||||||
|
return config('hosting.traefik.dynamic_config_path');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addCustomerRoute(string $domain, string $ip): void
|
||||||
|
{
|
||||||
|
$domain = $this->sanitizeDomain($domain);
|
||||||
|
$ip = $this->sanitizeIp($ip);
|
||||||
|
|
||||||
|
$config = $this->loadConfig();
|
||||||
|
$routerKey = $this->routerKey($domain);
|
||||||
|
$serviceKey = $this->serviceKey($domain);
|
||||||
|
$port = (int) config('hosting.traefik.backend_port', 80);
|
||||||
|
$entrypoint = config('hosting.traefik.entrypoint', 'websecure');
|
||||||
|
$certResolver = config('hosting.traefik.cert_resolver', 'letsencrypt');
|
||||||
|
|
||||||
|
$config['http'] ??= [];
|
||||||
|
$config['http']['routers'] ??= [];
|
||||||
|
$config['http']['services'] ??= [];
|
||||||
|
|
||||||
|
$config['http']['routers'][$routerKey] = [
|
||||||
|
'rule' => 'Host(`'.$this->escapeHostRule($domain).'`)',
|
||||||
|
'entryPoints' => [$entrypoint],
|
||||||
|
'service' => $serviceKey,
|
||||||
|
'tls' => [
|
||||||
|
'certResolver' => $certResolver,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['http']['services'][$serviceKey] = [
|
||||||
|
'loadBalancer' => [
|
||||||
|
'servers' => [
|
||||||
|
['url' => "http://{$ip}:{$port}"],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeConfig($config);
|
||||||
|
Log::info('Traefik: route added', ['domain' => $domain, 'ip' => $ip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeCustomerRoute(string $domain): void
|
||||||
|
{
|
||||||
|
$domain = $this->sanitizeDomain($domain);
|
||||||
|
$config = $this->loadConfig();
|
||||||
|
|
||||||
|
$routerKey = $this->routerKey($domain);
|
||||||
|
$serviceKey = $this->serviceKey($domain);
|
||||||
|
|
||||||
|
unset($config['http']['routers'][$routerKey], $config['http']['services'][$serviceKey]);
|
||||||
|
|
||||||
|
$this->writeConfig($config);
|
||||||
|
Log::info('Traefik: route removed', ['domain' => $domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rebuildAllRoutes(iterable $customers): void
|
||||||
|
{
|
||||||
|
$config = $this->loadConfig();
|
||||||
|
$config['http'] ??= [];
|
||||||
|
$config['http']['routers'] = $this->preserveNonManaged($config['http']['routers'] ?? [], 'routers');
|
||||||
|
$config['http']['services'] = $this->preserveNonManaged($config['http']['services'] ?? [], 'services');
|
||||||
|
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (empty($customer->domain) || empty($customer->ip_address)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = $this->sanitizeDomain($customer->domain);
|
||||||
|
$ip = $this->sanitizeIp($customer->ip_address);
|
||||||
|
$routerKey = $this->routerKey($domain);
|
||||||
|
$serviceKey = $this->serviceKey($domain);
|
||||||
|
$port = (int) config('hosting.traefik.backend_port', 80);
|
||||||
|
$entrypoint = config('hosting.traefik.entrypoint', 'websecure');
|
||||||
|
$certResolver = config('hosting.traefik.cert_resolver', 'letsencrypt');
|
||||||
|
|
||||||
|
$config['http']['routers'][$routerKey] = [
|
||||||
|
'rule' => 'Host(`'.$this->escapeHostRule($domain).'`)',
|
||||||
|
'entryPoints' => [$entrypoint],
|
||||||
|
'service' => $serviceKey,
|
||||||
|
'tls' => ['certResolver' => $certResolver],
|
||||||
|
];
|
||||||
|
|
||||||
|
$config['http']['services'][$serviceKey] = [
|
||||||
|
'loadBalancer' => [
|
||||||
|
'servers' => [['url' => "http://{$ip}:{$port}"]],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeConfig($config);
|
||||||
|
Log::info('Traefik: all customer routes rebuilt');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reload(): void
|
||||||
|
{
|
||||||
|
$command = config('hosting.traefik.reload_command');
|
||||||
|
|
||||||
|
if (empty($command)) {
|
||||||
|
Log::info('Traefik: no reload command configured, skipping reload');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Process::timeout(30)->run($command);
|
||||||
|
|
||||||
|
if (! $result->successful()) {
|
||||||
|
throw new TraefikException(
|
||||||
|
'Traefik reload failed: '.$result->errorOutput(),
|
||||||
|
step: 'traefik_reload',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Traefik: reload triggered');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadConfig(): array
|
||||||
|
{
|
||||||
|
$path = $this->configPath();
|
||||||
|
|
||||||
|
if (! File::exists($path)) {
|
||||||
|
File::ensureDirectoryExists(dirname($path));
|
||||||
|
|
||||||
|
return ['http' => ['routers' => [], 'services' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = File::get($path);
|
||||||
|
|
||||||
|
if (trim($contents) === '') {
|
||||||
|
return ['http' => ['routers' => [], 'services' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$parsed = Yaml::parse($contents, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE);
|
||||||
|
|
||||||
|
return is_array($parsed) ? $parsed : ['http' => ['routers' => [], 'services' => []]];
|
||||||
|
} catch (ParseException $e) {
|
||||||
|
throw new TraefikException(
|
||||||
|
'Failed to parse Traefik config: '.$e->getMessage(),
|
||||||
|
step: 'traefik_parse',
|
||||||
|
previous: $e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeConfig(array $config): void
|
||||||
|
{
|
||||||
|
$path = $this->configPath();
|
||||||
|
File::ensureDirectoryExists(dirname($path));
|
||||||
|
|
||||||
|
$yaml = Yaml::dump($config, 6, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
|
||||||
|
|
||||||
|
if (str_contains($yaml, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $yaml)) {
|
||||||
|
throw new TraefikException('Refusing to write invalid Traefik YAML.', step: 'traefik_write');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempPath = $path.'.'.uniqid('tmp', true);
|
||||||
|
|
||||||
|
if (File::put($tempPath, $yaml) === false) {
|
||||||
|
throw new TraefikException('Failed to write temporary Traefik config.', step: 'traefik_write');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! @rename($tempPath, $path)) {
|
||||||
|
File::delete($tempPath);
|
||||||
|
throw new TraefikException('Atomic rename of Traefik config failed.', step: 'traefik_write');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preserveNonManaged(array $items, string $type): array
|
||||||
|
{
|
||||||
|
return array_filter(
|
||||||
|
$items,
|
||||||
|
fn ($key) => ! str_starts_with((string) $key, self::MANAGED_PREFIX),
|
||||||
|
ARRAY_FILTER_USE_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function routerKey(string $domain): string
|
||||||
|
{
|
||||||
|
return self::MANAGED_PREFIX.$this->slug($domain).'-router';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceKey(string $domain): string
|
||||||
|
{
|
||||||
|
return self::MANAGED_PREFIX.$this->slug($domain).'-service';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function slug(string $domain): string
|
||||||
|
{
|
||||||
|
return Str::slug(str_replace('.', '-', $domain), '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeDomain(string $domain): string
|
||||||
|
{
|
||||||
|
$domain = strtolower(trim($domain));
|
||||||
|
|
||||||
|
if (! preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/', $domain)) {
|
||||||
|
throw new TraefikException("Invalid domain: {$domain}", step: 'traefik_validation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeIp(string $ip): string
|
||||||
|
{
|
||||||
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
throw new TraefikException("Invalid IP: {$ip}", step: 'traefik_validation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeHostRule(string $domain): string
|
||||||
|
{
|
||||||
|
return str_replace(['`', '\\'], '', $domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
artisan
Executable file
18
artisan
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
27
bootstrap/app.php
Normal file
27
bootstrap/app.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->alias([
|
||||||
|
'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
|
||||||
|
'whmcs' => \App\Http\Middleware\VerifyWhmcsRequest::class,
|
||||||
|
'2fa' => \App\Http\Middleware\EnsureTwoFactorVerified::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$middleware->appendToGroup('web', [
|
||||||
|
\App\Http\Middleware\EnsureTwoFactorVerified::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
//
|
||||||
|
})->create();
|
||||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
9
bootstrap/providers.php
Normal file
9
bootstrap/providers.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\HostingServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
HostingServiceProvider::class,
|
||||||
|
];
|
||||||
89
composer.json
Normal file
89
composer.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "laravel/laravel",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": ["laravel", "framework"],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"bacon/bacon-qr-code": "^3.1",
|
||||||
|
"laravel/framework": "^13.8",
|
||||||
|
"laravel/tinker": "^3.0",
|
||||||
|
"pragmarx/google2fa-laravel": "^3.0",
|
||||||
|
"symfony/yaml": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/pail": "^1.2.5",
|
||||||
|
"laravel/pao": "^1.0.6",
|
||||||
|
"laravel/pint": "^1.27",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpunit/phpunit": "^12.5.12"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"setup": [
|
||||||
|
"composer install",
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
|
"@php artisan key:generate",
|
||||||
|
"@php artisan migrate --force",
|
||||||
|
"npm install --ignore-scripts",
|
||||||
|
"npm run build"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi @no_additional_args",
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
],
|
||||||
|
"pre-package-uninstall": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
8705
composer.lock
generated
Normal file
8705
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
composer.phar
Executable file
BIN
composer.phar
Executable file
Binary file not shown.
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application, which will be used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| other UI elements where an application name needs to be displayed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| the application so that it's available within Artisan commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. The timezone
|
||||||
|
| is set to "UTC" by default as it is suitable for most use cases.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by Laravel's translation / localization methods. This option can be
|
||||||
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is utilized by Laravel's encryption services and should be set
|
||||||
|
| to a random, 32 character string to ensure that all encrypted values
|
||||||
|
| are secure. You should do this prior to deploying the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
117
config/auth.php
Normal file
117
config/auth.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => env('AUTH_MODEL', User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the number of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
130
config/cache.php
Normal file
130
config/cache.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache store that will be used by the
|
||||||
|
| framework. This connection is utilized if another isn't explicitly
|
||||||
|
| specified when running a cache operation inside the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "array", "database", "file", "memcached",
|
||||||
|
| "redis", "dynamodb", "octane",
|
||||||
|
| "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_CACHE_CONNECTION'),
|
||||||
|
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||||
|
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||||
|
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||||
|
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||||
|
| stores, there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Serializable Classes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the classes that can be unserialized from cache
|
||||||
|
| storage. By default, no PHP classes will be unserialized from your
|
||||||
|
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serializable_classes' => false,
|
||||||
|
|
||||||
|
];
|
||||||
184
config/database.php
Normal file
184
config/database.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Pdo\Mysql;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for database operations. This is
|
||||||
|
| the connection which will be utilized unless another connection
|
||||||
|
| is explicitly specified when you execute a query / statement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are all of the database connections defined for your application.
|
||||||
|
| An example configuration is provided for each database system which
|
||||||
|
| is supported by Laravel. You're free to add / remove connections.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
'busy_timeout' => null,
|
||||||
|
'journal_mode' => null,
|
||||||
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mariadb' => [
|
||||||
|
'driver' => 'mariadb',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run on the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as Memcached. You may define your connection settings here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application for file storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure as many filesystem disks as necessary, and you
|
||||||
|
| may even configure multiple disks for the same driver. Examples for
|
||||||
|
| most supported storage drivers are configured here for reference.
|
||||||
|
|
|
||||||
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
115
config/hosting.php
Normal file
115
config/hosting.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'panel' => [
|
||||||
|
'url' => env('APP_URL', 'https://panel.hexahost.de'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'proxmox' => [
|
||||||
|
'url' => env('PROXMOX_URL', 'https://proxmox.example.com:8006'),
|
||||||
|
'token' => env('PROXMOX_TOKEN'),
|
||||||
|
'node' => env('PROXMOX_NODE', 'hyperion'),
|
||||||
|
'storage' => env('PROXMOX_STORAGE', 'vmdata'),
|
||||||
|
'bridge' => env('PROXMOX_BRIDGE', 'vmbr0'),
|
||||||
|
'public_bridge' => env('PROXMOX_PUBLIC_BRIDGE', 'vmbr1'),
|
||||||
|
'template_vmid' => env('PROXMOX_TEMPLATE_VMID'),
|
||||||
|
'timeout' => (int) env('PROXMOX_TIMEOUT', 120),
|
||||||
|
'verify_ssl' => env('PROXMOX_VERIFY_SSL', true),
|
||||||
|
'iso_storage' => env('PROXMOX_ISO_STORAGE', 'ISO'),
|
||||||
|
'iso_device' => env('PROXMOX_ISO_DEVICE', 'ide2'),
|
||||||
|
'console_ws_url' => env('PROXMOX_CONSOLE_WS_URL'),
|
||||||
|
'backup_storage' => env('PROXMOX_BACKUP_STORAGE', 'inett-PBS'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'vmid' => [
|
||||||
|
'range_start' => (int) env('VMID_RANGE_START', 2000),
|
||||||
|
'range_end' => (int) env('VMID_RANGE_END', 2999),
|
||||||
|
'release_after_hours' => (int) env('VMID_RELEASE_AFTER_HOURS', 48),
|
||||||
|
],
|
||||||
|
|
||||||
|
'snapshots' => [
|
||||||
|
'retention_hours' => (int) env('SNAPSHOT_RETENTION_HOURS', 48),
|
||||||
|
'auto_before_destructive' => env('SNAPSHOT_AUTO_BEFORE_DESTRUCTIVE', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
'backups' => [
|
||||||
|
'max_per_customer' => (int) env('MAX_BACKUPS_PER_CUSTOMER', 4),
|
||||||
|
'pbs_storage' => env('PROXMOX_BACKUP_STORAGE', 'inett-PBS'),
|
||||||
|
'enabled' => env('BACKUPS_ENABLED', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'iso_upload' => [
|
||||||
|
'enabled' => env('ISO_UPLOAD_ENABLED', true),
|
||||||
|
'max_per_customer' => (int) env('ISO_UPLOAD_MAX_PER_CUSTOMER', 1),
|
||||||
|
'max_size_mb' => (int) env('ISO_UPLOAD_MAX_SIZE_MB', 10240),
|
||||||
|
'retention_hours' => (int) env('ISO_UPLOAD_RETENTION_HOURS', 48),
|
||||||
|
],
|
||||||
|
|
||||||
|
'vm_power' => [
|
||||||
|
'rate_limit_per_minute' => (int) env('VM_POWER_RATE_LIMIT', 20),
|
||||||
|
],
|
||||||
|
|
||||||
|
'traefik' => [
|
||||||
|
'dynamic_config_path' => env('TRAEFIK_DYNAMIC_CONFIG_PATH', storage_path('traefik/dynamic/customers.yaml')),
|
||||||
|
'entrypoint' => env('TRAEFIK_ENTRYPOINT', 'websecure'),
|
||||||
|
'cert_resolver' => env('TRAEFIK_CERT_RESOLVER', 'letsencrypt'),
|
||||||
|
'backend_port' => (int) env('TRAEFIK_BACKEND_PORT', 80),
|
||||||
|
'public_ip' => env('TRAEFIK_PUBLIC_IP'),
|
||||||
|
'reload_command' => env('TRAEFIK_RELOAD_COMMAND'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'plesk' => [
|
||||||
|
'url' => env('PLESK_URL', 'https://plesk.example.com:8443'),
|
||||||
|
'user' => env('PLESK_USER'),
|
||||||
|
'password' => env('PLESK_PASS'),
|
||||||
|
'base_domain' => env('PLESK_BASE_DOMAIN', 'hexahost.de'),
|
||||||
|
'timeout' => (int) env('PLESK_TIMEOUT', 30),
|
||||||
|
'verify_ssl' => env('PLESK_VERIFY_SSL', true),
|
||||||
|
'mail_enabled' => env('PLESK_MAIL_ENABLED', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
'network' => [
|
||||||
|
'gateway' => env('HOSTING_GATEWAY', '10.32.0.1'),
|
||||||
|
'cidr' => (int) env('HOSTING_NETWORK_CIDR', 24),
|
||||||
|
'ip_pool_start' => env('HOSTING_IP_POOL_START', '10.32.0.10'),
|
||||||
|
'ip_pool_end' => env('HOSTING_IP_POOL_END', '10.32.0.254'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'public_network' => [
|
||||||
|
'gateway' => env('HOSTING_PUBLIC_GATEWAY', '185.45.149.241'),
|
||||||
|
'cidr' => (int) env('HOSTING_PUBLIC_CIDR', 28),
|
||||||
|
'ip_pool_start' => env('HOSTING_PUBLIC_POOL_START', '185.45.149.246'),
|
||||||
|
'ip_pool_end' => env('HOSTING_PUBLIC_POOL_END', '185.45.149.252'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'whmcs' => [
|
||||||
|
'enabled' => env('WHMCS_ENABLED', true),
|
||||||
|
'api_secret' => env('WHMCS_API_SECRET'),
|
||||||
|
'allowed_ips' => array_filter(array_map('trim', explode(',', env('WHMCS_ALLOWED_IPS', '')))),
|
||||||
|
'replay_window_seconds' => (int) env('WHMCS_REPLAY_WINDOW', 300),
|
||||||
|
],
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'cpu' => (int) env('HOSTING_DEFAULT_CPU', 2),
|
||||||
|
'ram' => (int) env('HOSTING_DEFAULT_RAM', 2048),
|
||||||
|
'disk' => (int) env('HOSTING_DEFAULT_DISK', 32),
|
||||||
|
],
|
||||||
|
|
||||||
|
'security' => [
|
||||||
|
'admin_2fa_required' => env('ADMIN_2FA_REQUIRED', true),
|
||||||
|
'login_max_attempts' => (int) env('LOGIN_MAX_ATTEMPTS', 5),
|
||||||
|
],
|
||||||
|
|
||||||
|
'console' => [
|
||||||
|
'proxy_enabled' => env('CONSOLE_PROXY_ENABLED', false),
|
||||||
|
'proxy_ws_url' => env('CONSOLE_PROXY_WS_URL'),
|
||||||
|
'proxy_secret' => env('CONSOLE_PROXY_SECRET'),
|
||||||
|
'proxy_validate_url' => env('CONSOLE_PROXY_VALIDATE_URL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'notifications' => [
|
||||||
|
'webhook_url' => env('HOSTING_WEBHOOK_URL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that is utilized to write
|
||||||
|
| messages to your logs. The value provided here should match one of
|
||||||
|
| the channels present in the list of "channels" configured below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Laravel
|
||||||
|
| utilizes the Monolog PHP logging library, which includes a variety
|
||||||
|
| of powerful log handlers and formatters that you're free to use.
|
||||||
|
|
|
||||||
|
| Available drivers: "single", "daily", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog", "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'days' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||||
|
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'handler_with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user