Add Pterodactyl game server provisioning service

Implements ProvisioningServiceInterface for game servers via Pterodactyl
panel API. Supports create, suspend, unsuspend, terminate, status, and
credential retrieval. Auto-creates panel user accounts.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 20:30:06 -05:00
parent c57318e2a5
commit f87de9e2a4
3 changed files with 437 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ class ProvisioningFactory
'vps' => app(VirtFusionService::class), 'vps' => app(VirtFusionService::class),
'dedicated' => app(SynergyCPService::class), 'dedicated' => app(SynergyCPService::class),
'hosting' => app(EnhanceService::class), 'hosting' => app(EnhanceService::class),
'game' => app(PterodactylService::class),
default => throw new InvalidArgumentException("Unsupported service type: {$serviceType}"), default => throw new InvalidArgumentException("Unsupported service type: {$serviceType}"),
}; };
} }

View File

@@ -0,0 +1,431 @@
<?php
declare(strict_types=1);
namespace App\Services\Provisioning;
use App\Models\ProvisioningLog;
use App\Models\Service;
use App\Notifications\ServiceCredentialsNotification;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Cashier\Subscription;
use RuntimeException;
class PterodactylService implements ProvisioningServiceInterface
{
private readonly string $baseUrl;
private readonly string $apiKey;
public function __construct()
{
$this->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<string, mixed>}
*/
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<string, mixed>}
*/
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<string, mixed>
*/
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<string, mixed>|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,
]);
}
}

View File

@@ -50,4 +50,9 @@ return [
'token' => env('ENHANCE_API_TOKEN'), 'token' => env('ENHANCE_API_TOKEN'),
], ],
'pterodactyl' => [
'url' => env('PTERODACTYL_PANEL_URL'),
'api_key' => env('PTERODACTYL_API_KEY'),
],
]; ];