diff --git a/website/app/Services/Provisioning/ProvisioningFactory.php b/website/app/Services/Provisioning/ProvisioningFactory.php index 0c4e90d..2af4ef2 100644 --- a/website/app/Services/Provisioning/ProvisioningFactory.php +++ b/website/app/Services/Provisioning/ProvisioningFactory.php @@ -17,6 +17,7 @@ class ProvisioningFactory 'vps' => app(VirtFusionService::class), 'dedicated' => app(SynergyCPService::class), 'hosting' => app(EnhanceService::class), + 'game' => app(PterodactylService::class), default => throw new InvalidArgumentException("Unsupported service type: {$serviceType}"), }; } diff --git a/website/app/Services/Provisioning/PterodactylService.php b/website/app/Services/Provisioning/PterodactylService.php new file mode 100644 index 0000000..4ec9031 --- /dev/null +++ b/website/app/Services/Provisioning/PterodactylService.php @@ -0,0 +1,431 @@ +baseUrl = rtrim(config('services.pterodactyl.url', ''), '/'); + $this->apiKey = config('services.pterodactyl.api_key', ''); + } + + public function provision(Subscription $subscription): Service + { + $plan = $subscription->plan; + $user = $subscription->user; + + if (! $plan) { + throw new RuntimeException('Subscription has no associated plan.'); + } + + $service = Service::create([ + 'user_id' => $user->id, + 'subscription_id' => $subscription->id, + 'plan_id' => $plan->id, + 'service_type' => 'game', + 'platform' => 'pterodactyl', + 'status' => 'pending', + ]); + + $this->logAction($service, 'provision', 'pending'); + + try { + $features = $plan->features ?? []; + $password = Str::random(16); + + // First, ensure the user exists on the Pterodactyl panel or create them + $panelUserId = $this->findOrCreatePanelUser($user); + + $response = $this->client()->post('/api/application/servers', [ + 'name' => $features['server_name'] ?? "ezscale-{$service->id}", + 'user' => $panelUserId, + 'egg' => $features['egg_id'] ?? 1, + 'docker_image' => $features['docker_image'] ?? 'ghcr.io/pterodactyl/yolks:java_17', + 'startup' => $features['startup_command'] ?? 'java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}', + 'environment' => $features['environment'] ?? [ + 'SERVER_JARFILE' => 'server.jar', + 'VANILLA_VERSION' => 'latest', + ], + 'limits' => [ + 'memory' => $features['memory'] ?? 1024, + 'swap' => $features['swap'] ?? 0, + 'disk' => $features['disk'] ?? 10240, + 'io' => $features['io'] ?? 500, + 'cpu' => $features['cpu'] ?? 100, + ], + 'feature_limits' => [ + 'databases' => $features['databases'] ?? 1, + 'backups' => $features['backups'] ?? 1, + 'allocations' => $features['allocations'] ?? 1, + ], + 'allocation' => [ + 'default' => $features['allocation_id'] ?? 1, + ], + 'deploy' => isset($features['allocation_id']) ? null : [ + 'locations' => $features['location_ids'] ?? [1], + 'dedicated_ip' => $features['dedicated_ip'] ?? false, + 'port_range' => $features['port_range'] ?? [], + ], + 'start_on_completion' => $features['start_on_completion'] ?? true, + ]); + + if (! $response->successful()) { + $this->logAction($service, 'provision', 'failed', $response->json(), $response->body()); + + throw new RuntimeException("Pterodactyl provisioning failed: {$response->body()}"); + } + + $data = $response->json(); + $serverData = $data['attributes'] ?? $data; + $serverId = (string) ($serverData['id'] ?? ''); + + // Extract IP and port from the allocation relationships + $allocation = $serverData['relationships']['allocations']['data'][0]['attributes'] ?? []; + $ipAddress = $allocation['ip'] ?? null; + $port = $allocation['port'] ?? null; + + $service->update([ + 'platform_service_id' => $serverId, + 'status' => 'active', + 'ipv4_address' => $ipAddress, + 'hostname' => $ipAddress ? "{$ipAddress}:{$port}" : null, + 'provisioned_at' => now(), + 'credentials' => [ + 'panel_user_id' => $panelUserId, + 'server_identifier' => $serverData['identifier'] ?? null, + ], + ]); + + $this->logAction($service, 'provision', 'success', $data); + + $service = $service->fresh(); + + if ($service->user) { + $service->user->notify(new ServiceCredentialsNotification($service, [ + 'username' => $user->email, + 'password' => 'Use your panel login credentials', + 'hostname' => $service->hostname ?? $service->ipv4_address ?? 'N/A', + 'ip_address' => $service->ipv4_address ?? 'Pending', + 'port' => $port, + 'panel_url' => "{$this->baseUrl}/server/{$serverData['identifier']}", + ])); + } + + return $service; + } catch (RuntimeException $e) { + throw $e; + } catch (\Exception $e) { + $this->logAction($service, 'provision', 'failed', errorMessage: $e->getMessage()); + + $service->update(['status' => 'failed']); + + throw new RuntimeException("Pterodactyl provisioning error: {$e->getMessage()}", 0, $e); + } + } + + public function suspend(Service $service): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'suspend', 'pending'); + + try { + $response = $this->client()->post("/api/application/servers/{$service->platform_service_id}/suspend"); + + if (! $response->successful()) { + $this->logAction($service, 'suspend', 'failed', $response->json(), $response->body()); + + return false; + } + + $service->update([ + 'status' => 'suspended', + 'suspended_at' => now(), + ]); + + $this->logAction($service, 'suspend', 'success'); + + return true; + } catch (\Exception $e) { + Log::error('Pterodactyl suspend failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'suspend', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + public function unsuspend(Service $service): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'unsuspend', 'pending'); + + try { + $response = $this->client()->post("/api/application/servers/{$service->platform_service_id}/unsuspend"); + + if (! $response->successful()) { + $this->logAction($service, 'unsuspend', 'failed', $response->json(), $response->body()); + + return false; + } + + $service->update([ + 'status' => 'active', + 'suspended_at' => null, + ]); + + $this->logAction($service, 'unsuspend', 'success'); + + return true; + } catch (\Exception $e) { + Log::error('Pterodactyl unsuspend failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'unsuspend', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + public function terminate(Service $service): bool + { + $this->validateServicePlatform($service); + + $this->logAction($service, 'terminate', 'pending'); + + try { + $response = $this->client()->delete("/api/application/servers/{$service->platform_service_id}"); + + if (! $response->successful()) { + $this->logAction($service, 'terminate', 'failed', $response->json(), $response->body()); + + return false; + } + + $service->update([ + 'status' => 'terminated', + 'terminated_at' => now(), + ]); + + $this->logAction($service, 'terminate', 'success'); + + return true; + } catch (\Exception $e) { + Log::error('Pterodactyl terminate failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + $this->logAction($service, 'terminate', 'failed', errorMessage: $e->getMessage()); + + return false; + } + } + + /** + * @return array{status: string, ip_address?: string, hostname?: string, platform_data?: array} + */ + public function getStatus(Service $service): array + { + $this->validateServicePlatform($service); + + try { + $response = $this->client()->get("/api/application/servers/{$service->platform_service_id}", [ + 'include' => 'allocations', + ]); + + if (! $response->successful()) { + return ['status' => 'unknown']; + } + + $data = $response->json(); + $serverData = $data['attributes'] ?? $data; + + // Map Pterodactyl statuses to our internal statuses + $status = match (true) { + ($serverData['suspended'] ?? false) => 'suspended', + isset($serverData['status']) && $serverData['status'] === 'installing' => 'installing', + default => 'active', + }; + + // Try to get resource usage from the client API + $resourceData = $this->getResourceUsage($service); + + return [ + 'status' => $status, + 'ip_address' => $service->ipv4_address, + 'hostname' => $service->hostname, + 'platform_data' => array_merge($serverData, [ + 'resources' => $resourceData, + ]), + ]; + } catch (\Exception $e) { + Log::error('Pterodactyl getStatus failed', [ + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return ['status' => 'unknown']; + } + } + + /** + * @return array{username?: string, password?: string, url?: string, additional?: array} + */ + public function getCredentials(Service $service): array + { + $this->validateServicePlatform($service); + + $stored = $service->credentials ?? []; + $identifier = $stored['server_identifier'] ?? null; + + return [ + 'username' => $service->user?->email ?? 'see panel', + 'password' => null, + 'url' => $identifier ? "{$this->baseUrl}/server/{$identifier}" : $this->baseUrl, + 'additional' => [ + 'ip_address' => $service->ipv4_address, + 'hostname' => $service->hostname, + 'panel_url' => $this->baseUrl, + 'server_identifier' => $identifier, + ], + ]; + } + + /** + * Get resource usage for a server. + * + * @return array + */ + private function getResourceUsage(Service $service): array + { + try { + $identifier = $service->credentials['server_identifier'] ?? null; + + if (! $identifier) { + return []; + } + + $response = $this->client()->get("/api/application/servers/{$service->platform_service_id}"); + + if (! $response->successful()) { + return []; + } + + $data = $response->json(); + $attributes = $data['attributes'] ?? []; + $limits = $attributes['limits'] ?? []; + + return [ + 'memory_limit' => $limits['memory'] ?? 0, + 'disk_limit' => $limits['disk'] ?? 0, + 'cpu_limit' => $limits['cpu'] ?? 0, + 'io_limit' => $limits['io'] ?? 0, + ]; + } catch (\Exception) { + return []; + } + } + + /** + * Find or create a user on the Pterodactyl panel. + * + * @param \App\Models\User $user + */ + private function findOrCreatePanelUser($user): int + { + // Search for existing user by email + $response = $this->client()->get('/api/application/users', [ + 'filter[email]' => $user->email, + ]); + + if ($response->successful()) { + $data = $response->json(); + $users = $data['data'] ?? []; + + foreach ($users as $panelUser) { + if (($panelUser['attributes']['email'] ?? '') === $user->email) { + return (int) $panelUser['attributes']['id']; + } + } + } + + // Create new user on the panel + $createResponse = $this->client()->post('/api/application/users', [ + 'email' => $user->email, + 'username' => Str::slug($user->name).'-'.$user->id, + 'first_name' => $user->name, + 'last_name' => 'EZSCALE', + ]); + + if (! $createResponse->successful()) { + throw new RuntimeException("Failed to create Pterodactyl panel user: {$createResponse->body()}"); + } + + $createData = $createResponse->json(); + + return (int) ($createData['attributes']['id'] ?? $createData['id'] ?? 0); + } + + private function client(): PendingRequest + { + return Http::withHeaders([ + 'Authorization' => "Bearer {$this->apiKey}", + ]) + ->baseUrl($this->baseUrl) + ->acceptJson() + ->contentType('application/json') + ->timeout(30); + } + + private function validateServicePlatform(Service $service): void + { + if (! $service->platform_service_id) { + throw new RuntimeException('Service has no platform service ID.'); + } + } + + /** + * @param array|null $response + */ + private function logAction( + Service $service, + string $action, + string $status, + ?array $response = null, + ?string $errorMessage = null, + ): void { + ProvisioningLog::create([ + 'service_id' => $service->id, + 'user_id' => $service->user_id, + 'action' => $action, + 'platform' => 'pterodactyl', + 'platform_response' => $response, + 'status' => $status, + 'error_message' => $errorMessage, + ]); + } +} diff --git a/website/config/services.php b/website/config/services.php index c3afbfc..692080d 100644 --- a/website/config/services.php +++ b/website/config/services.php @@ -50,4 +50,9 @@ return [ 'token' => env('ENHANCE_API_TOKEN'), ], + 'pterodactyl' => [ + 'url' => env('PTERODACTYL_PANEL_URL'), + 'api_key' => env('PTERODACTYL_API_KEY'), + ], + ];