feat: add Pterodactyl Wings support alonside Elytra Support

This commit is contained in:
Naterfute
2026-01-09 04:52:17 -08:00
parent 4a36716574
commit 2e81dd4726
173 changed files with 5825 additions and 1137 deletions

View File

@@ -140,7 +140,7 @@ fi
fi
# Make a location and node for the panel
php artisan p:location:make -n --short local --long Local
php artisan p:node:make -n --name local --description "Development Node" --locationId 1 --fqdn localhost --internal-fqdn $ELYTRA_INTERNAL_IP --public 1 --scheme http --proxy 0 --maxMemory 1024 --maxDisk 10240 --overallocateMemory 0 --overallocateDisk 0
php artisan p:node:make -n --name local --description "Development Node" --locationId 1 --fqdn localhost --internal-fqdn $ELYTRA_INTERNAL_IP --public 1 --scheme http --proxy 0 --maxMemory 1024 --maxDisk 10240 --overallocateMemory 0 --overallocateDisk 0 --daemonType elytra
echo "Adding dummy allocations..."
if [ "$DB_CONNECTION" = "mysql" ] || [ "$DB_CONNECTION" = "mariadb" ]; then

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
.git/
.vscode/
.env
.github/workflows/ci.yaml
# Elytra binary

View File

@@ -24,7 +24,9 @@ class MakeNodeCommand extends Command
{--uploadSize= : Enter the maximum upload filesize.}
{--daemonListeningPort= : Enter the wings listening port.}
{--daemonSFTPPort= : Enter the wings SFTP listening port.}
{--daemonBase= : Enter the base folder.}';
{--daemonBase= : Enter the base folder.}
{--daemonType= : Enter the daemon Backend.}
{--backupDisk= : Enter the Backup type}';
protected $description = 'Creates a new node on the system via the CLI.';
@@ -64,6 +66,8 @@ class MakeNodeCommand extends Command
$data['daemonListen'] = $this->option('daemonListeningPort') ?? $this->ask('Enter the wings listening port', '8080');
$data['daemonSFTP'] = $this->option('daemonSFTPPort') ?? $this->ask('Enter the wings SFTP listening port', '2022');
$data['daemonBase'] = $this->option('daemonBase') ?? $this->ask('Enter the base folder', '/var/lib/pterodactyl/volumes');
$data['daemonType'] = $this->option('daemonType') ?? $this->ask('Enter the daemon backend', 'elytra');
$data['backupDisk'] = $this->option('backupDisk') ?? $this->ask('Enter the Backup Disk', 'rustic_local');
$node = $this->creationService->handle($data);
$this->line('Successfully created a new node on the location ' . $data['location_id'] . ' with the name ' . $data['name'] . ' and has an id of ' . $node->id . '.');

View File

@@ -0,0 +1,11 @@
<?php
namespace Pterodactyl\Contracts\Daemon;
use Pterodactyl\Models\Node;
interface Daemon
{
public function getConfiguration(Node $node): array;
public function getAutoDeploy(Node $node, string $token): string;
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Pterodactyl\Enums\Captcha;
enum Captchas: string
{
case NONE = 'none';
case TURNSTILE = 'turnstile';
case HCAPTCHA = 'hcaptcha';
case RECAPTCHA = 'recaptcha';
private const DESCRIPTION_MAP = [
self::NONE->value => 'Disabled',
self::TURNSTILE->value => 'Cloudflare Turnstile',
self::HCAPTCHA->value => 'HCaptcha',
self::RECAPTCHA->value => 'Google ReCaptcha',
];
public static function all(): array
{
$result = [];
foreach (self::cases() as $case) {
$result[$case->value] = self::DESCRIPTION_MAP[$case->value];
}
return $result;
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Pterodactyl\Enums\Daemon;
use Illuminate\Support\Facades\Log;
enum Adapters: string
{
case ADAPTER_WINGS = 'wings';
case ADAPTER_WINGS_S3 = 's3';
case ADAPTER_ELYTRA = 'elytra';
case ADAPTER_RUSTIC_LOCAL = 'rustic_local';
case ADAPTER_RUSTIC_S3 = 'rustic_s3';
private const ELYTRA = [
self::ADAPTER_ELYTRA, // NOTE: This is local storage without Rustic
self::ADAPTER_RUSTIC_LOCAL,
self::ADAPTER_RUSTIC_S3,
];
private const WINGS = [
self::ADAPTER_WINGS, // NOTE: This is local storage
self::ADAPTER_WINGS_S3,
];
public static function all(): array
{
return array_column(self::cases(), 'value', 'value');
}
public static function all_sorted(): array
{
return ['elytra' => self::all_elytra(), 'wings' => self::all_wings()];
}
public static function all_elytra(): array
{
return array_column(self::ELYTRA, "value");
}
public static function all_wings(): array
{
return array_column(self::WINGS, "value");
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Pterodactyl\Enums\Daemon;
enum DaemonType: string
{
case WINGS = 'wings';
case ELYTRA = 'elytra';
private const CLASS_MAP = [
self::WINGS->value => \Pterodactyl\Models\Daemons\Wings::class,
self::ELYTRA->value => \Pterodactyl\Models\Daemons\Elytra::class,
];
private const RESOURCE_MAP = [
self::WINGS->value => \Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra\ResourceUtilizationController::class,
self::ELYTRA->value => \Pterodactyl\Http\Controllers\Api\Client\Servers\Wings\ResourceUtilizationController::class,
];
public static function all(): array
{
return array_column(self::cases(), 'value', 'value');
}
public static function allResources(): array
{
return self::RESOURCE_MAP;
}
public static function allClass(): array
{
return self::CLASS_MAP;
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Pterodactyl\Enums\Subdomain;
use Pterodactyl\Services\Subdomain\Features\FactorioSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\MinecraftSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\RustSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\ScpSlSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\TeamSpeakSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\VintageStorySubdomainFeature;
enum Features: string
{
case FACTORIO = "subdomain_factorio";
case MINECRAFT = "subdomain_minecraft";
case RUST = "subdomain_rust";
case SCPSL = "subdomain_scpsl";
case TEAMSPEAK = "subdomain_teamspeak";
case VINTAGESTORY = "subdomain_vintagestory";
private const CLASS_MAP = [
self::FACTORIO->value => FactorioSubdomainFeature::class,
self::MINECRAFT->value => MinecraftSubdomainFeature::class,
self::RUST->value => RustSubdomainFeature::class,
self::SCPSL->value => ScpSlSubdomainFeature::class,
self::TEAMSPEAK->value => TeamSpeakSubdomainFeature::class,
self::VINTAGESTORY->value => VintageStorySubdomainFeature::class,
];
public static function all(): array
{
$result = [];
foreach (self::cases() as $case) {
$result[$case->value] = $case->getClassName();
}
return $result;
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public function getClassName(): string
{
return self::CLASS_MAP[$this->value];
}
public static function getClass(string $provider): string
{
return self::from($provider)->getClassName();
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Pterodactyl\Enums\Subdomain;
use Pterodactyl\Services\Dns\Providers\CloudflareProvider;
use Pterodactyl\Services\Dns\Providers\HetznerProvider;
use Pterodactyl\Services\Dns\Providers\Route53Provider;
enum Providers: string
{
case CLOUDFLARE = 'cloudflare';
case HETZNER = 'hetzner';
case ROUTE53 = 'route53';
private const CLASS_MAP = [
self::CLOUDFLARE->value => CloudflareProvider::class,
self::HETZNER->value => HetznerProvider::class,
self::ROUTE53->value => Route53Provider::class,
];
private const DESCRIPTION_MAP = [
self::CLOUDFLARE->value => 'Cloudflare DNS service',
self::HETZNER->value => 'Hetzner DNS Console',
self::ROUTE53->value => 'AWS Route53 Dns Service',
];
public static function all(): array
{
$result = [];
foreach (self::cases() as $case) {
$result[$case->value] = self::getClass($case->value);
}
return $result;
}
/**
* Returns providers with name and description
*/
public static function allWithDescriptions(): array
{
$result = [];
foreach (self::cases() as $case) {
$result[$case->value] = [
"name" => $case->value,
"description" => self::DESCRIPTION_MAP[$case->value]
];
}
return $result;
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public function getClassName(): string
{
return self::CLASS_MAP[$this->value];
}
public static function getClass(string $provider): string
{
return self::from($provider)->getClassName();
}
}

View File

@@ -20,8 +20,7 @@ class NodeAutoDeployController extends Controller
private ApiKeyRepository $repository,
private Encrypter $encrypter,
private KeyCreationService $keyCreationService,
) {
}
) {}
/**
* Generates a new API key for the logged-in user with only permission to read

View File

@@ -8,28 +8,47 @@ use Pterodactyl\Models\Node;
use Spatie\QueryBuilder\QueryBuilder;
use Pterodactyl\Http\Controllers\Controller;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Illuminate\Support\Facades\Log;
class NodeController extends Controller
{
/**
* NodeController constructor.
*/
public function __construct(private ViewFactory $view)
{
}
/**
* NodeController constructor.
*/
public function __construct(private ViewFactory $view) {}
/**
* Returns a listing of nodes on the system.
*/
public function index(Request $request): View
{
$nodes = QueryBuilder::for(
Node::query()->with('location')->withCount('servers')
)
->allowedFilters(['uuid', 'name'])
->allowedSorts(['id'])
->paginate(25);
/**
* Returns a listing of nodes on the system.
*/
public function index(Request $request): View
{
$nodes = QueryBuilder::for(
Node::query()->with('location')->withCount('servers')
)
->allowedFilters(['uuid', 'name'])
->allowedSorts(['id'])
->paginate(25);
return $this->view->make('admin.nodes.index', ['nodes' => $nodes]);
}
foreach ($nodes as $node) {
$stats = app('Pterodactyl\Repositories\Eloquent\NodeRepository')->getUsageStatsRaw($node);
// NOTE: Pre-creating stats so we donn't do it in the blade
$memoryPercent = ($stats['memory']['value'] / $stats['memory']['base_limit']) * 100;
$diskPercent = ($stats['disk']['value'] / $stats['disk']['base_limit']) * 100;
$node->memory_percent = round($memoryPercent);
$node->memory_color = $memoryPercent < 50 ? '#50af51' : ($memoryPercent < 70 ? '#e0a800' : '#d9534f');
$node->allocated_memory = humanizeSize($stats['memory']['value'] * 1024 * 1024);
$node->total_memory = humanizeSize($stats['memory']['max'] * 1024 * 1024);
$node->disk_percent = round($diskPercent);
$node->disk_color = $diskPercent < 50 ? '#50af51' : ($diskPercent < 70 ? '#e0a800' : '#d9534f');
$node->allocated_disk = humanizeSize($stats['disk']['value'] * 1024 * 1024);
$node->total_disk = humanizeSize($stats['disk']['max'] * 1024 * 1024);
}
return $this->view->make('admin.nodes.index', ['nodes' => $nodes]);
}
}

View File

@@ -16,6 +16,8 @@ use Pterodactyl\Services\Helpers\SoftwareVersionService;
use Pterodactyl\Repositories\Eloquent\LocationRepository;
use Pterodactyl\Repositories\Eloquent\AllocationRepository;
use Illuminate\Support\Facades\DB;
use Pterodactyl\Enums\Daemon\DaemonType;
use Pterodactyl\Enums\Daemon\Adapters;
class NodeViewController extends Controller
{
@@ -39,10 +41,11 @@ class NodeViewController extends Controller
public function index(Request $request, Node $node): View
{
$node = $this->repository->loadLocationAndServerCount($node);
$stats = $this->repository->getUsageStats($node);
return $this->view->make('admin.nodes.view.index', [
'node' => $node,
'stats' => $this->repository->getUsageStats($node),
'stats' => $stats,
'version' => $this->versionService,
]);
}
@@ -55,6 +58,8 @@ class NodeViewController extends Controller
return $this->view->make('admin.nodes.view.settings', [
'node' => $node,
'locations' => $this->locationRepository->all(),
'daemonTypes' => DaemonType::all(),
'backupDisks' => Adapters::all_sorted(),
]);
}
@@ -76,7 +81,7 @@ class NodeViewController extends Controller
$this->plainInject(['node' => Collection::wrap($node)->only(['id'])]);
switch (DB::getPdo()->getAttribute(DB::getPdo()::ATTR_DRIVER_NAME)) {
case 'mysql':
default:
return $this->view->make('admin.nodes.view.allocation', [
'node' => $node,
'allocations' => Allocation::query()->where('node_id', $node->id)

View File

@@ -9,6 +9,8 @@ use Illuminate\Http\Response;
use Pterodactyl\Models\Allocation;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Enums\Daemon\Adapters;
use Pterodactyl\Enums\Daemon\DaemonType;
use Illuminate\View\Factory as ViewFactory;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Nodes\NodeUpdateService;
@@ -45,8 +47,7 @@ class NodesController extends Controller
protected NodeUpdateService $updateService,
protected SoftwareVersionService $versionService,
protected ViewFactory $view,
) {
}
) {}
/**
* Displays create new node page.
@@ -60,7 +61,7 @@ class NodesController extends Controller
return redirect()->route('admin.locations');
}
return $this->view->make('admin.nodes.new', ['locations' => $locations]);
return $this->view->make('admin.nodes.new', ['locations' => $locations, 'daemonTypes' => DaemonType::all(), 'backupDisks' => Adapters::all_sorted()]);
}
/**

View File

@@ -25,8 +25,7 @@ class CreateServerController extends Controller
private NodeRepository $nodeRepository,
private ServerCreationService $creationService,
private ViewFactory $view,
) {
}
) {}
/**
* Displays the create server page.

View File

@@ -16,9 +16,7 @@ class ServerController extends Controller
/**
* ServerController constructor.
*/
public function __construct(private ViewFactory $view)
{
}
public function __construct(private ViewFactory $view) {}
/**
* Returns all the servers that exist on the system using a paginated result set. If

View File

@@ -10,6 +10,7 @@ use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Servers\EnvironmentService;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Repositories\Eloquent\NestRepository;
use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Pterodactyl\Repositories\Eloquent\MountRepository;
@@ -34,8 +35,7 @@ class ServerViewController extends Controller
private ServerRepository $repository,
private EnvironmentService $environmentService,
private ViewFactory $view,
) {
}
) {}
/**
* Returns the index view for a server.

View File

@@ -12,6 +12,7 @@ use Pterodactyl\Services\Captcha\CaptchaManager;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Contracts\Repository\SettingsRepositoryInterface;
use Pterodactyl\Http\Requests\Admin\Settings\CaptchaSettingsFormRequest;
use Pterodactyl\Enums\Captcha\Captchas;
class CaptchaController extends Controller
{
@@ -32,13 +33,9 @@ class CaptchaController extends Controller
*/
public function index(): View
{
return $this->view->make('admin.settings.captcha', [
'providers' => [
'none' => 'Disabled',
'turnstile' => 'Cloudflare Turnstile',
'hcaptcha' => 'hCaptcha',
'recaptcha' => 'Google reCAPTCHA',
],
'providers' => Captchas::all(),
]);
}
@@ -51,13 +48,13 @@ class CaptchaController extends Controller
public function update(CaptchaSettingsFormRequest $request): RedirectResponse
{
$values = $request->normalize();
foreach ($values as $key => $value) {
// Encrypt secret keys before storing
if (in_array($key, \Pterodactyl\Providers\SettingsServiceProvider::getEncryptedKeys()) && !empty($value)) {
$value = $this->encrypter->encrypt($value);
}
$this->settings->set('settings::' . $key, $value);
}

View File

@@ -11,6 +11,7 @@ use Pterodactyl\Services\Subdomain\SubdomainManagementService;
use Pterodactyl\Exceptions\Dns\DnsProviderException;
use Illuminate\Contracts\View\Factory as ViewFactory;
use Pterodactyl\Http\Requests\Admin\Settings\DomainFormRequest;
use Pterodactyl\Enums\Subdomain\Providers;
class DomainsController extends Controller
{
@@ -220,20 +221,7 @@ class DomainsController extends Controller
*/
private function getAvailableProviders(): array
{
return [
'cloudflare' => [
'name' => 'Cloudflare',
'description' => 'Cloudflare DNS service',
],
'hetzner' => [
'name' => 'Hetzner',
'description' => 'Hetzner DNS Console',
],
'route53' => [
'name' => 'Route53',
'description' => 'AWS Route53 Dns Service',
],
];
return Providers::allWithDescriptions();
}
/**
@@ -241,11 +229,7 @@ class DomainsController extends Controller
*/
private function getProviderClass(string $provider): string
{
$providers = [
'cloudflare' => \Pterodactyl\Services\Dns\Providers\CloudflareProvider::class,
'hetzner' => \Pterodactyl\Services\Dns\Providers\HetznerProvider::class,
'route53' => \Pterodactyl\Services\Dns\Providers\Route53Provider::class,
];
$providers = Providers::all();
if (!isset($providers[$provider])) {
throw new \Exception("Unsupported DNS provider: {$provider}");
@@ -254,4 +238,3 @@ class DomainsController extends Controller
return $providers[$provider];
}
}

View File

@@ -37,6 +37,7 @@ class ClientController extends ClientApiController
'name',
'description',
'external_id',
'daemonType',
AllowedFilter::custom('*', new MultiFieldServerFilter()),
]);

View File

@@ -0,0 +1,53 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Models\Server;
use Pterodactyl\Transformers\Api\Client\ServerTransformer;
use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\GetServerRequest;
use Pterodactyl\Enums\Daemon\DaemonType;
class ServerController extends ClientApiController
{
/**
* ServerController constructor.
*/
public function __construct(private GetUserPermissionsService $permissionsService)
{
parent::__construct();
}
/**
* Transform an individual server into a response that can be consumed by a
* client using the API.
*/
public function index(GetServerRequest $request, Server $server): array
{
$server->loadMissing('node');
$daemonType = $server->node?->daemonType;
return $this->fractal->item($server)
->transformWith($this->getTransformer(ServerTransformer::class))
->addMeta([
'daemonType' => $daemonType,
'is_server_owner' => $request->user()->id === $server->owner_id,
'user_permissions' => $this->permissionsService->handle($server, $request->user()),
])
->toArray();
}
public function resources(GetServerRequest $request, Server $server): array
{
$server->loadMissing('node');
$daemonType = $server->node?->daemonType ?? 'elytra';
$controllers = DaemonType::allResources();
$controllerClass = $controllers[$daemonType];
$controller = app($controllerClass);
return $controller->__invoke($request, $server);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Permission;
use Pterodactyl\Models\ActivityLog;
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Transformers\Api\Client\ActivityLogTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
class ActivityLogController extends ClientApiController
{
/**
* Returns the activity logs for a server.
*/
public function __invoke(ClientApiRequest $request, Server $server): array
{
$this->authorize(Permission::ACTION_ACTIVITY_READ, $server);
$activity = QueryBuilder::for($server->activity())
->with('actor')
->allowedSorts(['timestamp'])
->allowedFilters([AllowedFilter::partial('event')])
->whereNotIn('activity_logs.event', ActivityLog::DISABLED_EVENTS)
->when(config('activity.hide_admin_activity'), function (Builder $builder) use ($server) {
// We could do this with a query and a lot of joins, but that gets pretty
// painful so for now we'll execute a simpler query.
$subusers = $server->subusers()->pluck('user_id')->merge($server->owner_id);
$builder->select('activity_logs.*')
->leftJoin('users', function (JoinClause $join) {
$join->on('users.id', 'activity_logs.actor_id')
->where('activity_logs.actor_type', (new User())->getMorphClass());
})
->where(function (Builder $builder) use ($subusers) {
$builder->whereNull('users.id')
->orWhere('users.root_admin', 0)
->orWhereIn('users.id', $subusers);
});
})
->paginate(min($request->query('per_page', 25), 100))
->appends($request->query());
return $this->fractal->collection($activity)
->transformWith($this->getTransformer(ActivityLogTransformer::class))
->toArray();
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
@@ -434,4 +434,4 @@ class BackupsController extends ClientApiController
'backup_count' => count($backupUuids),
]);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Facades\Activity;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\BadResponseException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\SendCommandRequest;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class CommandController extends ClientApiController
{
/**
* CommandController constructor.
*/
public function __construct(private DaemonCommandRepository $repository)
{
parent::__construct();
}
/**
* Send a command to a running server.
*
* @throws DaemonConnectionException
*/
public function index(SendCommandRequest $request, Server $server): Response
{
try {
$this->repository->setServer($server)->send($request->input('command'));
} catch (DaemonConnectionException $exception) {
$previous = $exception->getPrevious();
if ($previous instanceof BadResponseException) {
if (
$previous->getResponse() instanceof ResponseInterface
&& $previous->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY
) {
throw new HttpException(Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception);
}
}
throw $exception;
}
Activity::event('server:console.command')->property('command', $request->input('command'))->log();
return $this->returnNoContent();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Database;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Services\Databases\DatabasePasswordService;
use Pterodactyl\Transformers\Api\Client\DatabaseTransformer;
use Pterodactyl\Services\Databases\DatabaseManagementService;
use Pterodactyl\Services\Databases\DeployServerDatabaseService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\GetDatabasesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\StoreDatabaseRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\DeleteDatabaseRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\RotatePasswordRequest;
class DatabaseController extends ClientApiController
{
/**
* DatabaseController constructor.
*/
public function __construct(
private DeployServerDatabaseService $deployDatabaseService,
private DatabaseManagementService $managementService,
private DatabasePasswordService $passwordService,
) {
parent::__construct();
}
/**
* Return all the databases that belong to the given server.
*/
public function index(GetDatabasesRequest $request, Server $server): array
{
return $this->fractal->collection($server->databases)
->transformWith($this->getTransformer(DatabaseTransformer::class))
->toArray();
}
/**
* Create a new database for the given server and return it.
*
* @throws \Throwable
* @throws \Pterodactyl\Exceptions\Service\Database\TooManyDatabasesException
* @throws \Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException
*/
public function store(StoreDatabaseRequest $request, Server $server): array
{
$database = $this->deployDatabaseService->handle($server, $request->validated());
Activity::event('server:database.create')
->subject($database)
->property('name', $database->database)
->log();
return $this->fractal->item($database)
->parseIncludes(['password'])
->transformWith($this->getTransformer(DatabaseTransformer::class))
->toArray();
}
/**
* Rotates the password for the given server model and returns a fresh instance to
* the caller.
*
* @throws \Throwable
*/
public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array
{
$this->passwordService->handle($database);
$database->refresh();
Activity::event('server:database.rotate-password')
->subject($database)
->property('name', $database->database)
->log();
return $this->fractal->item($database)
->parseIncludes(['password'])
->transformWith($this->getTransformer(DatabaseTransformer::class))
->toArray();
}
/**
* Removes a database from the server.
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function delete(DeleteDatabaseRequest $request, Server $server, Database $database): Response
{
$this->managementService->delete($database);
Activity::event('server:database.delete')
->subject($database)
->property('name', $database->database)
->log();
return new Response('', Response::HTTP_NO_CONTENT);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
@@ -123,4 +123,5 @@ class ElytraJobsController extends ClientApiController
return new JsonResponse($result);
}
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Carbon\CarbonImmutable;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
use Pterodactyl\Transformers\Api\Client\FileObjectTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\PullFileRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ChmodFilesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest;
class FileController extends ClientApiController
{
/**
* FileController constructor.
*/
public function __construct(
private NodeJWTService $jwtService,
private DaemonFileRepository $fileRepository,
) {
parent::__construct();
}
/**
* Returns a listing of files in a given directory.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function directory(ListFilesRequest $request, Server $server): array
{
$contents = $this->fileRepository
->setServer($server)
->getDirectory($request->get('directory') ?? '/');
return $this->fractal->collection($contents)
->transformWith($this->getTransformer(FileObjectTransformer::class))
->toArray();
}
/**
* Return the contents of a specified file for the user.
*
* @throws \Throwable
*/
public function contents(GetFileContentsRequest $request, Server $server): Response
{
$response = $this->fileRepository->setServer($server)->getContent(
$request->get('file'),
config('pterodactyl.files.max_edit_size')
);
Activity::event('server:file.read')->property('file', $request->get('file'))->log();
return new Response($response, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
}
/**
* Generates a one-time token with a link that the user can use to
* download a given file.
*
* @throws \Throwable
*/
public function download(GetFileContentsRequest $request, Server $server): array
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($request->user())
->setClaims([
'file_path' => rawurldecode($request->get('file')),
'server_uuid' => $server->uuid,
])
->handle($server->node, $request->user()->id . $server->uuid);
Activity::event('server:file.download')->property('file', $request->get('file'))->log();
return [
'object' => 'signed_url',
'attributes' => [
'url' => sprintf(
'%s/download/file?token=%s',
$server->node->getConnectionAddress(),
$token->toString()
),
],
];
}
/**
* Writes the contents of the specified file to the server.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function write(WriteFileContentRequest $request, Server $server): JsonResponse
{
$this->fileRepository->setServer($server)->putContent($request->get('file'), $request->getContent());
Activity::event('server:file.write')->property('file', $request->get('file'))->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Creates a new folder on the server.
*
* @throws \Throwable
*/
public function create(CreateFolderRequest $request, Server $server): JsonResponse
{
$this->fileRepository
->setServer($server)
->createDirectory($request->input('name'), $request->input('root', '/'));
Activity::event('server:file.create-directory')
->property('name', $request->input('name'))
->property('directory', $request->input('root'))
->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Renames a file on the remote machine.
*
* @throws \Throwable
*/
public function rename(RenameFileRequest $request, Server $server): JsonResponse
{
$this->fileRepository
->setServer($server)
->renameFiles($request->input('root'), $request->input('files'));
Activity::event('server:file.rename')
->property('directory', $request->input('root'))
->property('files', $request->input('files'))
->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Copies a file on the server.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function copy(CopyFileRequest $request, Server $server): JsonResponse
{
$this->fileRepository
->setServer($server)
->copyFile($request->input('location'));
Activity::event('server:file.copy')->property('file', $request->input('location'))->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function compress(CompressFilesRequest $request, Server $server): array
{
$file = $this->fileRepository->setServer($server)->compressFiles(
$request->input('root'),
$request->input('files')
);
Activity::event('server:file.compress')
->property('directory', $request->input('root'))
->property('files', $request->input('files'))
->log();
return $this->fractal->item($file)
->transformWith($this->getTransformer(FileObjectTransformer::class))
->toArray();
}
/**
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse
{
set_time_limit(300);
$this->fileRepository->setServer($server)->decompressFile(
$request->input('root'),
$request->input('file')
);
Activity::event('server:file.decompress')
->property('directory', $request->input('root'))
->property('files', $request->input('file'))
->log();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Deletes files or folders for the server in the given root directory.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function delete(DeleteFileRequest $request, Server $server): JsonResponse
{
$this->fileRepository->setServer($server)->deleteFiles(
$request->input('root'),
$request->input('files')
);
Activity::event('server:file.delete')
->property('directory', $request->input('root'))
->property('files', $request->input('files'))
->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Updates file permissions for file(s) in the given root directory.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function chmod(ChmodFilesRequest $request, Server $server): JsonResponse
{
$this->fileRepository->setServer($server)->chmodFiles(
$request->input('root'),
$request->input('files')
);
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Requests that a file be downloaded from a remote location by Wings.
*
* @throws \Throwable
*/
public function pull(PullFileRequest $request, Server $server): JsonResponse
{
$this->fileRepository->setServer($server)->pull(
$request->input('url'),
$request->input('directory'),
$request->safe(['filename', 'use_header', 'foreground'])
);
Activity::event('server:file.pull')
->property('directory', $request->input('directory'))
->property('url', $request->input('url'))
->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\UploadFileRequest;
class FileUploadController extends ClientApiController
{
/**
* FileUploadController constructor.
*/
public function __construct(
private NodeJWTService $jwtService,
) {
parent::__construct();
}
/**
* Returns an url where files can be uploaded to.
*/
public function __invoke(UploadFileRequest $request, Server $server): JsonResponse
{
return new JsonResponse([
'object' => 'signed_url',
'attributes' => [
'url' => $this->getUploadUrl($server, $request->user()),
],
]);
}
/**
* Returns an url where files can be uploaded to.
*/
protected function getUploadUrl(Server $server, User $user): string
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($user)
->setClaims(['server_uuid' => $server->uuid])
->handle($server->node, $user->id . $server->uuid);
return sprintf(
'%s/upload/file?token=%s',
$server->node->getConnectionAddress(),
$token->toString()
);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;

View File

@@ -0,0 +1,35 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\SendPowerRequest;
class PowerController extends ClientApiController
{
/**
* PowerController constructor.
*/
public function __construct(private DaemonPowerRepository $repository)
{
parent::__construct();
}
/**
* Send a power action to a server.
*/
public function index(SendPowerRequest $request, Server $server): Response
{
$this->repository->setServer($server)->send(
$request->input('signal')
);
Activity::event(strtolower("server:power.{$request->input('signal')}"))->log();
return $this->returnNoContent();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Carbon\Carbon;
use Pterodactyl\Models\Server;
use Illuminate\Cache\Repository;
use Pterodactyl\Transformers\Api\Client\StatsTransformer;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\GetServerRequest;
class ResourceUtilizationController extends ClientApiController
{
/**
* ResourceUtilizationController constructor.
*/
public function __construct(private Repository $cache, private DaemonServerRepository $repository)
{
parent::__construct();
}
/**
* Return the current resource utilization for a server. This value is cached for up to
* 20 seconds at a time to ensure that repeated requests to this endpoint do not cause
* a flood of unnecessary API calls.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function __invoke(GetServerRequest $request, Server $server): array
{
$key = "resources:$server->uuid";
$stats = $this->cache->remember($key, Carbon::now()->addSeconds(20), function () use ($server) {
return $this->repository->setServer($server)->getDetails();
});
return $this->fractal->item($stats)
->transformWith($this->getTransformer(StatsTransformer::class))
->toArray();
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Carbon\Carbon;
use Illuminate\Http\Request;

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Pterodactyl\Models\Task;
use Illuminate\Http\Response;

View File

@@ -0,0 +1,35 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Pterodactyl\Models\Server;
use Pterodactyl\Transformers\Api\Client\ServerTransformer;
use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\GetServerRequest;
class ServerController extends ClientApiController
{
/**
* ServerController constructor.
*/
public function __construct(private GetUserPermissionsService $permissionsService)
{
parent::__construct();
}
/**
* Transform an individual server into a response that can be consumed by a
* client using the API.
*/
public function index(GetServerRequest $request, Server $server): array
{
return $this->fractal->item($server)
->transformWith($this->getTransformer(ServerTransformer::class))
->addMeta([
'is_server_owner' => $request->user()->id === $server->owner_id,
'user_permissions' => $this->permissionsService->handle($server, $request->user()),
])
->toArray();
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Exception;
use Illuminate\Http\Response;
@@ -94,7 +94,7 @@ class SettingsController extends ClientApiController
$original = $server->image;
$defaultImage = $server->getDefaultDockerImage();
if (empty($defaultImage)) {
throw new BadRequestHttpException('No default docker image available for this server\'s egg.');
}
@@ -140,98 +140,95 @@ class SettingsController extends ClientApiController
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse
{
try {
$eggId = $request->input('egg_id');
$nestId = $request->input('nest_id');
$previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId);
// Log the preview action
Activity::event('server:settings.egg-preview')
->property([
'current_egg_id' => $server->egg_id,
'preview_egg_id' => $eggId,
'preview_nest_id' => $nestId,
])
->log();
return new JsonResponse($previewData);
} catch (Exception $e) {
Log::error('Failed to preview egg change', [
'server_id' => $server->id,
'egg_id' => $request->input('egg_id'),
'nest_id' => $request->input('nest_id'),
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse
{
try {
$eggId = $request->input('egg_id');
$nestId = $request->input('nest_id');
/**
* Apply egg configuration changes asynchronously.
* This dispatches a background job to handle the complete egg change process.
*
* @throws \Throwable
*/
public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse
{
try {
$eggId = $request->input('egg_id');
$nestId = $request->input('nest_id');
$dockerImage = $request->input('docker_image');
$startupCommand = $request->input('startup_command');
$environment = $request->input('environment', []);
$shouldBackup = $request->input('should_backup', false);
$shouldWipe = $request->input('should_wipe', false);
$result = $this->eggChangeService->applyEggChangeAsync(
$server,
$request->user(),
$eggId,
$nestId,
$dockerImage,
$startupCommand,
$environment,
$shouldBackup,
$shouldWipe
);
Activity::event('server:software.change-queued')
->property([
'operation_id' => $result['operation_id'],
'from_egg' => $server->egg_id,
'to_egg' => $eggId,
'should_backup' => $shouldBackup,
'should_wipe' => $shouldWipe,
])
->log();
return new JsonResponse($result, Response::HTTP_ACCEPTED);
} catch (Exception $e) {
Log::error('Failed to apply egg change', [
'server_id' => $server->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
$previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId);
public function getOperationStatus(Server $server, string $operationId): JsonResponse
{
$operation = $this->operationService->getOperation($server, $operationId);
return new JsonResponse($this->operationService->formatOperationResponse($operation));
}
// Log the preview action
Activity::event('server:settings.egg-preview')
->property([
'current_egg_id' => $server->egg_id,
'preview_egg_id' => $eggId,
'preview_nest_id' => $nestId,
])
->log();
public function getServerOperations(Server $server): JsonResponse
{
$operations = $this->operationService->getServerOperations($server);
return new JsonResponse(['operations' => $operations]);
}
return new JsonResponse($previewData);
} catch (Exception $e) {
Log::error('Failed to preview egg change', [
'server_id' => $server->id,
'egg_id' => $request->input('egg_id'),
'nest_id' => $request->input('nest_id'),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Apply egg configuration changes asynchronously.
* This dispatches a background job to handle the complete egg change process.
*
* @throws \Throwable
*/
public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse
{
try {
$eggId = $request->input('egg_id');
$nestId = $request->input('nest_id');
$dockerImage = $request->input('docker_image');
$startupCommand = $request->input('startup_command');
$environment = $request->input('environment', []);
$shouldBackup = $request->input('should_backup', false);
$shouldWipe = $request->input('should_wipe', false);
$result = $this->eggChangeService->applyEggChangeAsync(
$server,
$request->user(),
$eggId,
$nestId,
$dockerImage,
$startupCommand,
$environment,
$shouldBackup,
$shouldWipe
);
Activity::event('server:software.change-queued')
->property([
'operation_id' => $result['operation_id'],
'from_egg' => $server->egg_id,
'to_egg' => $eggId,
'should_backup' => $shouldBackup,
'should_wipe' => $shouldWipe,
])
->log();
return new JsonResponse($result, Response::HTTP_ACCEPTED);
} catch (Exception $e) {
Log::error('Failed to apply egg change', [
'server_id' => $server->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function getOperationStatus(Server $server, string $operationId): JsonResponse
{
$operation = $this->operationService->getOperation($server, $operationId);
return new JsonResponse($this->operationService->formatOperationResponse($operation));
}
public function getServerOperations(Server $server): JsonResponse
{
$operations = $this->operationService->getServerOperations($server);
return new JsonResponse(['operations' => $operations]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Pterodactyl\Models\Server;
use Pterodactyl\Facades\Activity;
@@ -140,16 +140,16 @@ class StartupController extends ClientApiController
public function processCommand(GetStartupRequest $request, Server $server): array
{
$command = $request->input('command', $server->startup);
// Temporarily update the server's startup command for processing
$originalStartup = $server->startup;
$server->startup = $command;
$processedCommand = $this->startupCommandService->handle($server, false);
// Restore original startup command
$server->startup = $originalStartup;
return [
'processed_command' => $processedCommand,
];

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
@@ -28,7 +28,7 @@ class SubdomainController extends ClientApiController
public function index(Request $request): JsonResponse
{
$server = $request->attributes->get('server');
$this->authorize(Permission::ACTION_ALLOCATION_READ, $server);
try {
@@ -73,15 +73,15 @@ class SubdomainController extends ClientApiController
public function store(CreateSubdomainRequest $request): JsonResponse
{
$server = $request->attributes->get('server');
$this->authorize(Permission::ACTION_ALLOCATION_CREATE, $server);
$data = $request->validated();
try {
// Get ALL active subdomains for this server (more than one should be impossible, but PHP makes me angry)
$existingSubdomains = $server->subdomains()->where('is_active', true)->get();
$domain = Domain::where('id', $data['domain_id'])
->where('is_active', true)
->first();
@@ -148,7 +148,7 @@ class SubdomainController extends ClientApiController
public function destroy(Request $request): JsonResponse
{
$server = $request->attributes->get('server');
$this->authorize(Permission::ACTION_ALLOCATION_DELETE, $server);
try {
@@ -179,7 +179,6 @@ class SubdomainController extends ClientApiController
return response()->json([
'error' => 'Failed to delete subdomain(s).'
], 422);
}
}
@@ -189,9 +188,9 @@ class SubdomainController extends ClientApiController
public function checkAvailability(Request $request): JsonResponse
{
$server = $request->attributes->get('server');
$this->authorize(Permission::ACTION_ALLOCATION_READ, $server);
$request->validate([
'subdomain' => 'required|string|min:1|max:63|regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/',
'domain_id' => 'required|integer|exists:domains,id',
@@ -221,4 +220,5 @@ class SubdomainController extends ClientApiController
], 422);
}
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
class WebsocketController extends ClientApiController
{
/**
* WebsocketController constructor.
*/
public function __construct(
private NodeJWTService $jwtService,
private GetUserPermissionsService $permissionsService,
) {
parent::__construct();
}
/**
* Generates a one-time token that is sent along in every websocket call to the Daemon.
* This is a signed JWT that the Daemon then uses to verify the user's identity, and
* allows us to continually renew this token and avoid users maintaining sessions wrongly,
* as well as ensure that user's only perform actions they're allowed to.
*/
public function __invoke(ClientApiRequest $request, Server $server): JsonResponse
{
$user = $request->user();
if ($user->cannot(Permission::ACTION_WEBSOCKET_CONNECT, $server)) {
throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}
$permissions = $this->permissionsService->handle($server, $user);
$node = $server->node;
if (!is_null($server->transfer)) {
// Check if the user has permissions to receive transfer logs.
if (!in_array('admin.websocket.transfer', $permissions)) {
throw new HttpForbiddenException('You do not have permission to view server transfer logs.');
}
// Redirect the websocket request to the new node if the server has been archived.
if ($server->transfer->archived) {
$node = $server->transfer->newNode;
}
}
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(10))
->setUser($request->user())
->setClaims([
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->handle($node, $user->id . $server->uuid);
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getBrowserConnectionAddress());
return new JsonResponse([
'data' => [
'token' => $token->toString(),
'socket' => $socket . sprintf('/api/servers/%s/ws', $server->uuid),
],
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;

View File

@@ -0,0 +1,224 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\Permission;
use Illuminate\Auth\Access\AuthorizationException;
use Pterodactyl\Services\Backups\Wings\DeleteBackupService;
use Pterodactyl\Services\Backups\Wings\DownloadLinkService;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Services\Backups\Wings\InitiateBackupService;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
class BackupController extends ClientApiController
{
/**
* BackupController constructor.
*/
public function __construct(
private DaemonBackupRepository $daemonRepository,
private DeleteBackupService $deleteBackupService,
private InitiateBackupService $initiateBackupService,
private DownloadLinkService $downloadLinkService,
private BackupRepository $repository,
) {
parent::__construct();
}
/**
* Returns all the backups for a given server instance in a paginated
* result set.
*
* @throws AuthorizationException
*/
public function index(Request $request, Server $server): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new AuthorizationException();
}
$limit = min($request->query('per_page') ?? 20, 50);
return $this->fractal->collection($server->backups()->paginate($limit))
->transformWith($this->getTransformer(BackupTransformer::class))
->addMeta([
'backup_count' => $this->repository->getNonFailedBackups($server)->count(),
])
->toArray();
}
/**
* Starts the backup process for a server.
*
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
* @throws \Throwable
*/
public function store(StoreBackupRequest $request, Server $server): array
{
$action = $this->initiateBackupService
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
// Only set the lock status if the user even has permission to delete backups,
// otherwise ignore this status. This gets a little funky since it isn't clear
// how best to allow a user to create a backup that is locked without also preventing
// them from just filling up a server with backups that can never be deleted?
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
$action->setIsLocked((bool) $request->input('is_locked'));
}
$backup = $action->handle($server, $request->input('name'));
Activity::event('server:backup.start')
->subject($backup)
->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')])
->log();
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Toggles the lock status of a given backup for a server.
*
* @throws \Throwable
* @throws AuthorizationException
*/
public function toggleLock(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock';
$backup->update(['is_locked' => !$backup->is_locked]);
Activity::event($action)->subject($backup)->property('name', $backup->name)->log();
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Returns information about a single backup.
*
* @throws AuthorizationException
*/
public function view(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new AuthorizationException();
}
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Deletes a backup from the panel as well as the remote source where it is currently
* being stored.
*
* @throws \Throwable
*/
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$this->deleteBackupService->handle($backup);
Activity::event('server:backup.delete')
->subject($backup)
->property(['name' => $backup->name, 'failed' => !$backup->is_successful])
->log();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Download the backup for a given server instance. For daemon local files, the file
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
* which the user is redirected to.
*
* @throws \Throwable
* @throws AuthorizationException
*/
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
throw new AuthorizationException();
}
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_WINGS) {
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
}
$url = $this->downloadLinkService->handle($backup, $request->user());
Activity::event('server:backup.download')->subject($backup)->property('name', $backup->name)->log();
return new JsonResponse([
'object' => 'signed_url',
'attributes' => ['url' => $url],
]);
}
/**
* Handles restoring a backup by making a request to the Wings instance telling it
* to begin the process of finding (or downloading) the backup and unpacking it
* over the server files.
*
* If the "truncate" flag is passed through in this request then all the
* files that currently exist on the server will be deleted before restoring.
* Otherwise, the archive will simply be unpacked over the existing files.
*
* @throws \Throwable
*/
public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse
{
// Cannot restore a backup unless a server is fully installed and not currently
// processing a different backup restoration request.
if (!is_null($server->status)) {
throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.');
}
if (!$backup->is_successful && is_null($backup->completed_at)) {
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
}
$log = Activity::event('server:backup.restore')
->subject($backup)
->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]);
$log->transaction(function () use ($backup, $server, $request) {
// If the backup is for an S3 file we need to generate a unique Download link for
// it that will allow Wings to actually access the file.
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$url = $this->downloadLinkService->handle($backup, $request->user());
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => Server::STATUS_RESTORING_BACKUP]);
$this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate'));
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Carbon\CarbonImmutable;
use Illuminate\Http\Response;

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;

View File

@@ -0,0 +1,140 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Transformers\Api\Client\AllocationTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Services\Allocations\FindAssignableAllocationService;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\NewAllocationRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\DeleteAllocationRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\UpdateAllocationRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest;
class NetworkAllocationController extends ClientApiController
{
/**
* NetworkAllocationController constructor.
*/
public function __construct(
private FindAssignableAllocationService $assignableAllocationService,
private ServerRepository $serverRepository,
) {
parent::__construct();
}
/**
* Lists all the allocations available to a server and whether
* they are currently assigned as the primary for this server.
*/
public function index(GetNetworkRequest $request, Server $server): array
{
return $this->fractal->collection($server->allocations)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the primary allocation for a server.
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(UpdateAllocationRequest $request, Server $server, Allocation $allocation): array
{
$original = $allocation->notes;
$allocation->forceFill(['notes' => $request->input('notes')])->save();
if ($original !== $allocation->notes) {
Activity::event('server:allocation.notes')
->subject($allocation)
->property(['allocation' => $allocation->toString(), 'old' => $original, 'new' => $allocation->notes])
->log();
}
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the primary allocation for a server.
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function setPrimary(SetPrimaryAllocationRequest $request, Server $server, Allocation $allocation): array
{
$this->serverRepository->update($server->id, ['allocation_id' => $allocation->id]);
Activity::event('server:allocation.primary')
->subject($allocation)
->property('allocation', $allocation->toString())
->log();
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Set the notes for the allocation for a server.
*s.
*
* @throws DisplayException
*/
public function store(NewAllocationRequest $request, Server $server): array
{
if ($server->allocations()->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
$allocation = $this->assignableAllocationService->handle($server);
Activity::event('server:allocation.create')
->subject($allocation)
->property('allocation', $allocation->toString())
->log();
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Delete an allocation from a server.
*
* @throws DisplayException
*/
public function delete(DeleteAllocationRequest $request, Server $server, Allocation $allocation): JsonResponse
{
// Don't allow the deletion of allocations if the server does not have an
// allocation limit set.
if (empty($server->allocation_limit)) {
throw new DisplayException('You cannot delete allocations for this server: no allocation limit is set.');
}
if ($allocation->id === $server->allocation_id) {
throw new DisplayException('You cannot delete the primary allocation for this server.');
}
Allocation::query()->where('id', $allocation->id)->update([
'notes' => null,
'server_id' => null,
]);
Activity::event('server:allocation.delete')
->subject($allocation)
->property('allocation', $allocation->toString())
->log();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Carbon\Carbon;
use Pterodactyl\Models\Server;

View File

@@ -0,0 +1,184 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Schedule;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Helpers\Utilities;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Repositories\Eloquent\ScheduleRepository;
use Pterodactyl\Services\Schedules\ProcessScheduleService;
use Pterodactyl\Transformers\Api\Client\ScheduleTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\UpdateScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\TriggerScheduleRequest;
class ScheduleController extends ClientApiController
{
/**
* ScheduleController constructor.
*/
public function __construct(private ScheduleRepository $repository, private ProcessScheduleService $service)
{
parent::__construct();
}
/**
* Returns all the schedules belonging to a given server.
*/
public function index(ViewScheduleRequest $request, Server $server): array
{
$schedules = $server->schedules->loadMissing('tasks');
return $this->fractal->collection($schedules)
->transformWith($this->getTransformer(ScheduleTransformer::class))
->toArray();
}
/**
* Store a new schedule for a server.
*
* @throws DisplayException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/
public function store(StoreScheduleRequest $request, Server $server): array
{
/** @var Schedule $model */
$model = $this->repository->create([
'server_id' => $server->id,
'name' => $request->input('name'),
'cron_day_of_week' => $request->input('day_of_week'),
'cron_month' => $request->input('month'),
'cron_day_of_month' => $request->input('day_of_month'),
'cron_hour' => $request->input('hour'),
'cron_minute' => $request->input('minute'),
'is_active' => (bool) $request->input('is_active'),
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
]);
Activity::event('server:schedule.create')
->subject($model)
->property('name', $model->name)
->log();
return $this->fractal->item($model)
->transformWith($this->getTransformer(ScheduleTransformer::class))
->toArray();
}
/**
* Returns a specific schedule for the server.
*/
public function view(ViewScheduleRequest $request, Server $server, Schedule $schedule): array
{
if ($schedule->server_id !== $server->id) {
throw new NotFoundHttpException();
}
$schedule->loadMissing('tasks');
return $this->fractal->item($schedule)
->transformWith($this->getTransformer(ScheduleTransformer::class))
->toArray();
}
/**
* Updates a given schedule with the new data provided.
*
* @throws DisplayException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(UpdateScheduleRequest $request, Server $server, Schedule $schedule): array
{
$active = (bool) $request->input('is_active');
$data = [
'name' => $request->input('name'),
'cron_day_of_week' => $request->input('day_of_week'),
'cron_month' => $request->input('month'),
'cron_day_of_month' => $request->input('day_of_month'),
'cron_hour' => $request->input('hour'),
'cron_minute' => $request->input('minute'),
'is_active' => $active,
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
];
// Toggle the processing state of the scheduled task when it is enabled or disabled so that an
// invalid state can be reset without manual database intervention.
//
// @see https://github.com/pterodactyl/panel/issues/2425
if ($schedule->is_active !== $active) {
$data['is_processing'] = false;
}
$this->repository->update($schedule->id, $data);
Activity::event('server:schedule.update')
->subject($schedule)
->property(['name' => $schedule->name, 'active' => $active])
->log();
return $this->fractal->item($schedule->refresh())
->transformWith($this->getTransformer(ScheduleTransformer::class))
->toArray();
}
/**
* Executes a given schedule immediately rather than waiting on it's normally scheduled time
* to pass. This does not care about the schedule state.
*
* @throws \Throwable
*/
public function execute(TriggerScheduleRequest $request, Server $server, Schedule $schedule): JsonResponse
{
$this->service->handle($schedule, true);
Activity::event('server:schedule.execute')->subject($schedule)->property('name', $schedule->name)->log();
return new JsonResponse([], JsonResponse::HTTP_ACCEPTED);
}
/**
* Deletes a schedule and it's associated tasks.
*/
public function delete(DeleteScheduleRequest $request, Server $server, Schedule $schedule): JsonResponse
{
$this->repository->delete($schedule->id);
Activity::event('server:schedule.delete')->subject($schedule)->property('name', $schedule->name)->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Get the next run timestamp based on the cron data provided.
*
* @throws DisplayException
*/
protected function getNextRunAt(Request $request): Carbon
{
try {
return Utilities::getScheduleNextRunDate(
$request->input('minute'),
$request->input('hour'),
$request->input('day_of_month'),
$request->input('month'),
$request->input('day_of_week')
);
} catch (\Exception $exception) {
throw new DisplayException('The cron data provided does not evaluate to a valid expression.');
}
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Pterodactyl\Models\Task;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Schedule;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\Permission;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Eloquent\TaskRepository;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Transformers\Api\Client\TaskTransformer;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Exceptions\Service\ServiceLimitExceededException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest;
class ScheduleTaskController extends ClientApiController
{
/**
* ScheduleTaskController constructor.
*/
public function __construct(
private ConnectionInterface $connection,
private TaskRepository $repository,
) {
parent::__construct();
}
/**
* Create a new task for a given schedule and store it in the database.
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws ServiceLimitExceededException
*/
public function store(StoreTaskRequest $request, Server $server, Schedule $schedule): array
{
$limit = config('pterodactyl.client_features.schedules.per_schedule_task_limit', 10);
if ($schedule->tasks()->count() >= $limit) {
throw new ServiceLimitExceededException("Schedules may not have more than $limit tasks associated with them. Creating this task would put this schedule over the limit.");
}
if ($server->backup_limit === 0 && $request->action === 'backup') {
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
}
/** @var Task|null $lastTask */
$lastTask = $schedule->tasks()->orderByDesc('sequence_id')->first();
/** @var Task $task */
$task = $this->connection->transaction(function () use ($request, $schedule, $lastTask) {
$sequenceId = ($lastTask->sequence_id ?? 0) + 1;
$requestSequenceId = $request->integer('sequence_id', $sequenceId);
// Ensure that the sequence id is at least 1.
if ($requestSequenceId < 1) {
$requestSequenceId = 1;
}
// If the sequence id from the request is greater than or equal to the next available
// sequence id, we don't need to do anything special. Otherwise, we need to update
// the sequence id of all tasks that are greater than or equal to the request sequence
// id to be one greater than the current value.
if ($requestSequenceId < $sequenceId) {
$schedule->tasks()
->where('sequence_id', '>=', $requestSequenceId)
->increment('sequence_id');
$sequenceId = $requestSequenceId;
}
return $this->repository->create([
'schedule_id' => $schedule->id,
'sequence_id' => $sequenceId,
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => $request->boolean('continue_on_failure'),
]);
});
Activity::event('server:task.create')
->subject($schedule, $task)
->property(['name' => $schedule->name, 'action' => $task->action, 'payload' => $task->payload])
->log();
return $this->fractal->item($task)
->transformWith($this->getTransformer(TaskTransformer::class))
->toArray();
}
/**
* Updates a given task for a server.
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(StoreTaskRequest $request, Server $server, Schedule $schedule, Task $task): array
{
if ($schedule->id !== $task->schedule_id || $server->id !== $schedule->server_id) {
throw new NotFoundHttpException();
}
if ($server->backup_limit === 0 && $request->action === 'backup') {
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
}
$this->connection->transaction(function () use ($request, $schedule, $task) {
$sequenceId = $request->integer('sequence_id', $task->sequence_id);
// Ensure that the sequence id is at least 1.
if ($sequenceId < 1) {
$sequenceId = 1;
}
// Shift all other tasks in the schedule up or down to make room for the new task.
if ($sequenceId < $task->sequence_id) {
$schedule->tasks()
->where('sequence_id', '>=', $sequenceId)
->where('sequence_id', '<', $task->sequence_id)
->increment('sequence_id');
} elseif ($sequenceId > $task->sequence_id) {
$schedule->tasks()
->where('sequence_id', '>', $task->sequence_id)
->where('sequence_id', '<=', $sequenceId)
->decrement('sequence_id');
}
$this->repository->update($task->id, [
'sequence_id' => $sequenceId,
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => $request->boolean('continue_on_failure'),
]);
});
Activity::event('server:task.update')
->subject($schedule, $task)
->property(['name' => $schedule->name, 'action' => $task->action, 'payload' => $task->payload])
->log();
return $this->fractal->item($task->refresh())
->transformWith($this->getTransformer(TaskTransformer::class))
->toArray();
}
/**
* Delete a given task for a schedule. If there are subsequent tasks stored in the database
* for this schedule their sequence IDs are decremented properly.
*
* @throws \Exception
*/
public function delete(ClientApiRequest $request, Server $server, Schedule $schedule, Task $task): JsonResponse
{
if ($task->schedule_id !== $schedule->id || $schedule->server_id !== $server->id) {
throw new NotFoundHttpException();
}
if (!$request->user()->can(Permission::ACTION_SCHEDULE_UPDATE, $server)) {
throw new HttpForbiddenException('You do not have permission to perform this action.');
}
$schedule->tasks()
->where('sequence_id', '>', $task->sequence_id)
->decrement('sequence_id');
$task->delete();
Activity::event('server:task.delete')->subject($schedule, $task)->property('name', $schedule->name)->log();
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Pterodactyl\Models\Server;
use Pterodactyl\Transformers\Api\Client\ServerTransformer;

View File

@@ -0,0 +1,224 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Exception;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Services\Servers\ReinstallServerService;
use Pterodactyl\Services\ServerOperations\EggChangeService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetEggRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\PreviewEggRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ApplyEggChangeRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
class SettingsController extends ClientApiController
{
/**
* SettingsController constructor.
*/
public function __construct(
private ServerRepository $repository,
private ReinstallServerService $reinstallServerService,
private EggChangeService $eggChangeService,
) {
parent::__construct();
}
/**
* Renames a server.
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function rename(RenameServerRequest $request, Server $server): JsonResponse
{
$name = $request->input('name');
$description = $request->has('description') ? (string) $request->input('description') : $server->description;
$this->repository->update($server->id, [
'name' => $name,
'description' => $description,
]);
if ($server->name !== $name) {
Activity::event('server:settings.rename')
->property(['old' => $server->name, 'new' => $name])
->log();
}
if ($server->description !== $description) {
Activity::event('server:settings.description')
->property(['old' => $server->description, 'new' => $description])
->log();
}
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Reinstalls the server on the daemon.
*
* @throws \Throwable
*/
public function reinstall(ReinstallServerRequest $request, Server $server): JsonResponse
{
$this->reinstallServerService->handle($server);
Activity::event('server:reinstall')->log();
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
/**
* Changes the Docker image in use by the server.
*
* @throws \Throwable
*/
public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse
{
if (!in_array($request->input('docker_image'), array_values($server->egg->docker_images))) {
throw new BadRequestHttpException('The requested Docker image is not allowed for this server.');
}
$original = $server->image;
$server->forceFill(['image' => $request->input('docker_image')])->saveOrFail();
if ($original !== $server->image) {
Activity::event('server:startup.image')
->property(['old' => $original, 'new' => $request->input('docker_image')])
->log();
}
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Reset Startup Command
*/
private function resetStartupCommand(Server $server): JsonResponse
{
$server->startup = $server->egg->startup;
$server->save();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
/**
* Changes the egg for a server.
*
* @throws \Throwable
*/
public function changeEgg(SetEggRequest $request, Server $server): JsonResponse
{
$eggId = $request->input('egg_id');
$nestId = $request->input('nest_id');
$originalEggId = $server->egg_id;
$originalNestId = $server->nest_id;
// Check if the new Egg and Nest IDs are different from the current ones
if ($originalEggId !== $eggId || $originalNestId !== $nestId) {
// Update the server's Egg and Nest IDs
$server->egg_id = $eggId;
$server->nest_id = $nestId;
$server->save();
// Log an activity event for the Egg change
Activity::event('server:settings.egg')
->property(['original_egg_id' => $originalEggId, 'new_egg_id' => $eggId, 'original_nest_id' => $originalNestId, 'new_nest_id' => $nestId])
->log();
// Reset the server's startup command
$this->resetStartupCommand($server);
}
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
public function previewEggChange(PreviewEggRequest $request, Server $server): JsonResponse
{
try {
$eggId = $request->input('egg_id');
$nestId = $request->input('nest_id');
$previewData = $this->eggChangeService->previewEggChange($server, $eggId, $nestId);
// Log the preview action
Activity::event('server:settings.egg-preview')
->property([
'current_egg_id' => $server->egg_id,
'preview_egg_id' => $eggId,
'preview_nest_id' => $nestId,
])
->log();
return new JsonResponse($previewData);
} catch (Exception $e) {
Log::error('Failed to preview egg change', [
'server_id' => $server->id,
'egg_id' => $request->input('egg_id'),
'nest_id' => $request->input('nest_id'),
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Apply egg configuration changes asynchronously.
* This dispatches a background job to handle the complete egg change process.
*
* @throws \Throwable
*/
public function applyEggChange(ApplyEggChangeRequest $request, Server $server): JsonResponse
{
try {
$eggId = $request->input('egg_id');
$nestId = $request->input('nest_id');
$dockerImage = $request->input('docker_image');
$startupCommand = $request->input('startup_command');
$environment = $request->input('environment', []);
$shouldBackup = $request->input('should_backup', false);
$shouldWipe = $request->input('should_wipe', false);
$result = $this->eggChangeService->applyEggChangeAsync(
$server,
$request->user(),
$eggId,
$nestId,
$dockerImage,
$startupCommand,
$environment,
$shouldBackup,
$shouldWipe
);
Activity::event('server:software.change-queued')
->property([
'operation_id' => $result['operation_id'],
'from_egg' => $server->egg_id,
'to_egg' => $eggId,
'should_backup' => $shouldBackup,
'should_wipe' => $shouldWipe,
])
->log();
return new JsonResponse($result, Response::HTTP_ACCEPTED);
} catch (Exception $e) {
Log::error('Failed to apply egg change', [
'server_id' => $server->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Pterodactyl\Models\Server;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Services\Servers\StartupCommandService;
use Pterodactyl\Repositories\Eloquent\ServerVariableRepository;
use Pterodactyl\Transformers\Api\Client\EggVariableTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\GetStartupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest;
class StartupController extends ClientApiController
{
/**
* StartupController constructor.
*/
public function __construct(
private StartupCommandService $startupCommandService,
private ServerVariableRepository $repository,
) {
parent::__construct();
}
/**
* Returns the startup information for the server including all the variables.
*/
public function index(GetStartupRequest $request, Server $server): array
{
$startup = $this->startupCommandService->handle($server);
return $this->fractal->collection(
$server->variables()->where('user_viewable', true)->get()
)
->transformWith($this->getTransformer(EggVariableTransformer::class))
->addMeta([
'startup_command' => $startup,
'docker_images' => $server->egg->docker_images,
'raw_startup_command' => $server->startup,
])
->toArray();
}
/**
* Updates a single variable for a server.
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(UpdateStartupVariableRequest $request, Server $server): array
{
/** @var \Pterodactyl\Models\EggVariable $variable */
$variable = $server->variables()->where('env_variable', $request->input('key'))->first();
$original = $variable->server_value;
if (is_null($variable) || !$variable->user_viewable) {
throw new BadRequestHttpException('The environment variable you are trying to edit does not exist.');
} elseif (!$variable->user_editable) {
throw new BadRequestHttpException('The environment variable you are trying to edit is read-only.');
}
// Revalidate the variable value using the egg variable specific validation rules for it.
$this->validate($request, ['value' => $variable->rules]);
$this->repository->updateOrCreate([
'server_id' => $server->id,
'variable_id' => $variable->id,
], [
'variable_value' => $request->input('value') ?? '',
]);
$variable = $variable->refresh();
$variable->server_value = $request->input('value');
$startup = $this->startupCommandService->handle($server);
if ($variable->env_variable !== $request->input('value')) {
Activity::event('server:startup.edit')
->subject($variable)
->property([
'variable' => $variable->env_variable,
'old' => $original,
'new' => $request->input('value'),
])
->log();
}
return $this->fractal->item($variable)
->transformWith($this->getTransformer(EggVariableTransformer::class))
->addMeta([
'startup_command' => $startup,
'raw_startup_command' => $server->startup,
])
->toArray();
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Server;

View File

@@ -19,9 +19,7 @@ class AuthenticateServerAccess
/**
* AuthenticateServerAccess constructor.
*/
public function __construct()
{
}
public function __construct() {}
/**
* Authenticate that this server exists and is not suspended or marked as installing.

View File

@@ -0,0 +1,26 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class CheckDaemonType
{
public function handle(Request $request, Closure $next, string $daemon)
{
$server = $request->attributes->get('server');
$daemonType = $server->node->daemonType;
if (! $daemonType) {
abort(404);
}
if ($daemonType !== $daemon) {
abort(400, "This endpoint requires daemon type '{$daemon}', but server is using '{$daemonType}'.");
}
return $next($request);
}
}

View File

@@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Requests\Admin\Node;
use Pterodactyl\Rules\Fqdn;
use Pterodactyl\Models\Node;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Http\Requests\Admin\AdminFormRequest;
class NodeFormRequest extends AdminFormRequest
@@ -16,12 +17,14 @@ class NodeFormRequest extends AdminFormRequest
if ($this->method() === 'PATCH') {
$rules = Node::getRulesForUpdate($this->route()->parameter('node'));
$rules['internal_fqdn'] = ['nullable', 'string', Fqdn::make('scheme')];
return $rules;
}
$data = Node::getRules();
$data['fqdn'][] = Fqdn::make('scheme');
$data['internal_fqdn'] = ['nullable', 'string', Fqdn::make('scheme')];
log::info("rules", [$data]);
return $data;
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote\Backups;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Extensions\Filesystem\S3Filesystem;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class BackupRemoteUploadController extends Controller
{
public const DEFAULT_MAX_PART_SIZE = 5 * 1024 * 1024 * 1024;
/**
* BackupRemoteUploadController constructor.
*/
public function __construct(private BackupManager $backupManager) {}
/**
* Returns the required presigned urls to upload a backup to S3 cloud storage.
*
* @throws \Exception
* @throws \Throwable
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function __invoke(Request $request, string $backup): JsonResponse
{
// Get the node associated with the request.
/** @var \Pterodactyl\Models\Node $node */
$node = $request->attributes->get('node');
// Get the size query parameter.
$size = (int) $request->query('size');
if (empty($size)) {
throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.');
}
/** @var Backup $model */
$model = Backup::query()
->where('uuid', $backup)
->firstOrFail();
// Check that the backup is "owned" by the node making the request. This avoids other nodes
// from messing with backups that they don't own.
/** @var \Pterodactyl\Models\Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
}
// Prevent backups that have already been completed from trying to
// be uploaded again.
if (!is_null($model->completed_at)) {
throw new ConflictHttpException('This backup is already in a completed state.');
}
// Ensure we are using the S3 adapter.
$adapter = $this->backupManager->adapter();
if (!$adapter instanceof S3Filesystem) {
throw new BadRequestHttpException('The configured backup adapter is not an S3 compatible adapter.');
}
// The path where backup will be uploaded to
$path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid);
// Get the S3 client
$client = $adapter->getClient();
$expires = CarbonImmutable::now()->addMinutes((int) config('backups.presigned_url_lifespan', 60));
// Params for generating the presigned urls
$params = [
'Bucket' => $adapter->getBucket(),
'Key' => $path,
'ContentType' => 'application/x-gzip',
];
$storageClass = config('backups.disks.s3.storage_class');
if (!is_null($storageClass)) {
$params['StorageClass'] = $storageClass;
}
// Execute the CreateMultipartUpload request
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
// the other presigned urls.
$params['UploadId'] = $result->get('UploadId');
// Retrieve configured part size
$maxPartSize = $this->getConfiguredMaxPartSize();
// Create as many UploadPart presigned urls as needed
$parts = [];
for ($i = 0; $i < ($size / $maxPartSize); ++$i) {
$parts[] = $client->createPresignedRequest(
$client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])),
$expires
)->getUri()->__toString();
}
// Set the upload_id on the backup in the database.
$model->update(['upload_id' => $params['UploadId']]);
return new JsonResponse([
'parts' => $parts,
'part_size' => $maxPartSize,
]);
}
/**
* Get the configured maximum size of a single part in the multipart upload.
*
* The function tries to retrieve a configured value from the configuration.
* If no value is specified, a fallback value will be used.
*
* Note if the received config cannot be converted to int (0), is zero or is negative,
* the fallback value will be used too.
*
* The fallback value is {@see BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE}.
*/
private function getConfiguredMaxPartSize(): int
{
$maxPartSize = (int) config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
if ($maxPartSize <= 0) {
$maxPartSize = self::DEFAULT_MAX_PART_SIZE;
}
return $maxPartSize;
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote\Backups;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Extensions\Filesystem\S3Filesystem;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
class BackupStatusController extends Controller
{
/**
* BackupStatusController constructor.
*/
public function __construct(private BackupManager $backupManager) {}
/**
* Handles updating the state of a backup.
*
* @throws \Throwable
*/
public function index(ReportBackupCompleteRequest $request, string $backup): JsonResponse
{
// Get the node associated with the request.
/** @var \Pterodactyl\Models\Node $node */
$node = $request->attributes->get('node');
/** @var Backup $model */
$model = Backup::query()
->where('uuid', $backup)
->firstOrFail();
// Check that the backup is "owned" by the node making the request. This avoids other nodes
// from messing with backups that they don't own.
/** @var \Pterodactyl\Models\Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
}
if ($model->is_successful) {
throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.');
}
$action = $request->boolean('successful') ? 'server:backup.complete' : 'server:backup.fail';
$log = Activity::event($action)->subject($model, $model->server)->property('name', $model->name);
$log->transaction(function () use ($model, $request) {
$successful = $request->boolean('successful');
$model->fill([
'is_successful' => $successful,
// Change the lock state to unlocked if this was a failed backup so that it can be
// deleted easily. Also does not make sense to have a locked backup on the system
// that is failed.
'is_locked' => $successful ? $model->is_locked : false,
'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null,
'bytes' => $successful ? $request->input('size') : 0,
'completed_at' => CarbonImmutable::now(),
])->save();
// Check if we are using the s3 backup adapter. If so, make sure we mark the backup as
// being completed in S3 correctly.
$adapter = $this->backupManager->adapter();
if ($adapter instanceof S3Filesystem) {
$this->completeMultipartUpload($model, $adapter, $successful, $request->input('parts'));
}
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Handles toggling the restoration status of a server. The server status field should be
* set back to null, even if the restoration failed. This is not an unsolvable state for
* the server, and the user can keep trying to restore, or just use the reinstall button.
*
* The only thing the successful field does is update the entry value for the audit logs
* table tracking for this restoration.
*
* @throws \Throwable
*/
public function restore(Request $request, string $backup): JsonResponse
{
/** @var Backup $model */
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$model->server->update(['status' => null]);
Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed')
->subject($model, $model->server)
->property('name', $model->name)
->log();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Marks a multipart upload in a given S3-compatible instance as failed or successful for
* the given backup.
*
* @throws \Exception
* @throws DisplayException
*/
protected function completeMultipartUpload(Backup $backup, S3Filesystem $adapter, bool $successful, ?array $parts): void
{
// This should never really happen, but if it does don't let us fall victim to Amazon's
// wildly fun error messaging. Just stop the process right here.
if (empty($backup->upload_id)) {
// A failed backup doesn't need to error here, this can happen if the backup encounters
// an error before we even start the upload. AWS gives you tooling to clear these failed
// multipart uploads as needed too.
if (!$successful) {
return;
}
throw new DisplayException('Cannot complete backup request: no upload_id present on model.');
}
$params = [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
'UploadId' => $backup->upload_id,
];
$client = $adapter->getClient();
if (!$successful) {
$client->execute($client->getCommand('AbortMultipartUpload', $params));
return;
}
// Otherwise send a CompleteMultipartUpload request.
$params['MultipartUpload'] = [
'Parts' => [],
];
if (is_null($parts)) {
$params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts'];
} else {
foreach ($parts as $part) {
$params['MultipartUpload']['Parts'][] = [
'ETag' => $part['etag'],
'PartNumber' => $part['part_number'],
];
}
}
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
}
}

View File

@@ -68,11 +68,11 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
SubdomainManagementService $subdomainService
): void {
$operation = null;
try {
$operation = ServerOperation::where('operation_id', $this->operationId)->firstOrFail();
$operation->markAsStarted();
Activity::actor($this->user)->event('server:software.change-started')
->property([
'operation_id' => $this->operationId,
@@ -105,7 +105,6 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
$this->logSuccessfulChange();
$operation->markAsCompleted('Software configuration applied successfully. Server installation completed.');
} catch (Exception $e) {
$this->handleJobFailure($e, $operation);
throw $e;
@@ -121,18 +120,18 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
$currentEgg = $this->server->egg;
$targetEgg = Egg::find($this->eggId);
$backupName = sprintf(
'Software Change: %s → %s (%s)',
$currentEgg->name ?? 'Unknown',
$targetEgg->name ?? 'Unknown',
now()->format('M j, g:i A')
);
if (strlen($backupName) > 190) {
$backupName = substr($backupName, 0, 187) . '...';
}
try {
$result = $elytraJobService->submitJob(
$this->server,
@@ -159,7 +158,6 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
$operation->updateProgress('Backup job submitted successfully');
return $result['job_id'];
} catch (\Exception $e) {
throw new BackupFailedException('Failed to create backup before egg change: ' . $e->getMessage());
}
@@ -211,12 +209,12 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
private function wipeServerFiles(DaemonFileRepository $fileRepository, ServerOperation $operation): void
{
$operation->updateProgress('Wiping server files...');
try {
$contents = $fileRepository->setServer($this->server)->getDirectory('/');
if (!empty($contents)) {
$filesToDelete = array_map(function($item) {
$filesToDelete = array_map(function ($item) {
return $item['name'];
}, $contents);
@@ -273,7 +271,7 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
SubdomainManagementService $subdomainService
): void {
$operation->updateProgress('Applying software configuration...');
DB::transaction(function () use ($egg, $startupModificationService, $reinstallServerService, $operation, $subdomainService) {
// Check if we need to remove subdomain before changing egg
$activeSubdomain = $this->server->activeSubdomain;
@@ -282,14 +280,14 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
$tempServer = clone $this->server;
$tempServer->egg = $egg;
$tempServer->egg_id = $egg->id;
// If new egg doesn't support subdomains, delete the existing subdomain
if (!$tempServer->supportsSubdomains()) {
$operation->updateProgress('Removing incompatible subdomain...');
try {
$subdomainService->deleteSubdomain($activeSubdomain);
Activity::actor($this->user)->event('server:subdomain.deleted-egg-change')
->property([
'operation_id' => $this->operationId,
@@ -305,13 +303,13 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
'subdomain' => $activeSubdomain->full_domain,
'error' => $e->getMessage(),
]);
// Continue with egg change even if subdomain deletion fails
$operation->updateProgress('Warning: Could not fully remove subdomain, continuing with egg change...');
}
}
}
if ($this->server->egg_id !== $this->eggId || $this->server->nest_id !== $this->nestId) {
$this->server->update([
'egg_id' => $this->eggId,
@@ -331,7 +329,7 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
$operation->updateProgress('Reinstalling server...');
$reinstallServerService->handle($updatedServer);
$operation->updateProgress('Finalizing installation...');
});
}
@@ -398,4 +396,4 @@ class ApplyEggChangeJob extends Job implements ShouldQueue
])
->log();
}
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Pterodactyl\Enums\Daemon\Adapters;
/**
* Backup model
@@ -41,9 +42,13 @@ class Backup extends Model
public const RESOURCE_NAME = 'backup';
// Backup adapters
public const ADAPTER_WINGS = 'wings';
public const ADAPTER_ELYTRA = 'elytra'; // Preferred name for local backups
public const ADAPTER_AWS_S3 = 's3';
// Wings Adapters
public const ADAPTER_WINGS = 'wings';
// Elytra Backups
public const ADAPTER_ELYTRA = 'elytra';
public const ADAPTER_RUSTIC_LOCAL = 'rustic_local';
public const ADAPTER_RUSTIC_S3 = 'rustic_s3';
@@ -82,6 +87,7 @@ class Backup extends Model
return in_array($this->disk, [self::ADAPTER_RUSTIC_LOCAL, self::ADAPTER_RUSTIC_S3]);
}
/**
* Check if this backup is stored locally (not in cloud storage).
*/
@@ -95,7 +101,7 @@ class Backup extends Model
*/
public function getRepositoryType(): ?string
{
return match($this->disk) {
return match ($this->disk) {
self::ADAPTER_RUSTIC_LOCAL => 'local',
self::ADAPTER_RUSTIC_S3 => 's3',
default => null,
@@ -161,9 +167,8 @@ class Backup extends Model
*/
public function getElytraAdapterType(): string
{
return match($this->disk) {
self::ADAPTER_WINGS => 'elytra', // Legacy support: wings -> elytra
self::ADAPTER_ELYTRA => 'elytra', // Direct mapping for new backups
return match ($this->disk) {
self::ADAPTER_ELYTRA => 'elytra',
self::ADAPTER_AWS_S3 => 's3',
self::ADAPTER_RUSTIC_LOCAL => 'rustic_local',
self::ADAPTER_RUSTIC_S3 => 'rustic_s3',
@@ -219,4 +224,4 @@ class Backup extends Model
{
return $this->query()->where($field ?? $this->getRouteKeyName(), $value)->firstOrFail();
}
}
}

1
app/Models/Daemons/.info Normal file
View File

@@ -0,0 +1 @@
These files are used in app/Models/Node.php for it's configuration getting and such

View File

@@ -0,0 +1,93 @@
<?php
namespace Pterodactyl\Models\Daemons;
use Illuminate\Support\Str;
use Pterodactyl\Models\Node;
use Illuminate\Container\Container;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Contracts\Daemon\Daemon;
class Elytra implements Daemon
{
public function getConfiguration(Node $node): array
{
return [
'debug' => false,
'uuid' => $node->uuid,
'token_id' => $node->daemon_token_id,
'token' => Container::getInstance()->make(Encrypter::class)->decrypt($node->daemon_token),
'api' => [
'host' => '0.0.0.0',
'port' => $node->daemonListen,
'ssl' => [
'enabled' => (!$node->behind_proxy && $node->scheme === 'https'),
'cert' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/fullchain.pem',
'key' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/privkey.pem',
],
'upload_limit' => $node->upload_size,
],
'system' => [
'data' => $node->daemonBase,
'sftp' => [
'bind_port' => $node->daemonSFTP,
],
'backups' => [
'rustic' => $this->getBackupConfiguration(),
],
],
'allowed_mounts' => $node->mounts->pluck('source')->toArray(),
'remote' => route('index'),
'allowed_origins' => [
config('app.url'),
],
];
}
private function getBackupConfiguration()
{
$localConfig = config('backups.disks.rustic_local', []);
$s3Config = config('backups.disks.rustic_s3', []);
return [
// Path to rustic binary
'binary_path' => $localConfig['binary_path'] ?? 'rustic',
// Repository version (optional, default handled by rustic)
'repository_version' => $localConfig['repository_version'] ?? 2,
// Pack size configuration for performance tuning
'tree_pack_size_mb' => $localConfig['tree_pack_size_mb'] ?? 4,
'data_pack_size_mb' => $localConfig['data_pack_size_mb'] ?? 32,
// Local repository configuration
'local' => [
'enabled' => !empty($localConfig),
'repository_path' => $localConfig['repository_path'] ?? '/var/lib/pterodactyl/rustic-repos',
'use_cold_storage' => $localConfig['use_cold_storage'] ?? false,
'hot_repository_path' => $localConfig['hot_repository_path'] ?? '',
],
// S3 repository configuration
's3' => [
'enabled' => !empty($s3Config['bucket']),
'endpoint' => $s3Config['endpoint'] ?? '',
'region' => $s3Config['region'] ?? 'us-east-1',
'bucket' => $s3Config['bucket'] ?? '',
'use_cold_storage' => $s3Config['use_cold_storage'] ?? false,
'hot_bucket' => $s3Config['hot_bucket'] ?? '',
'cold_storage_class' => $s3Config['cold_storage_class'] ?? 'GLACIER',
'force_path_style' => $s3Config['force_path_style'] ?? false,
'disable_ssl' => $s3Config['disable_ssl'] ?? false,
'ca_cert_path' => $s3Config['ca_cert_path'] ?? '',
],
];
}
public function getAutoDeploy(Node $node, string $token): string
{
$debugFlag = config('app.debug') ? ' --allow-insecure' : '';
return "cd /etc/elytra && sudo elytra configure --panel-url " . config('app.url') . " --token " . $token . " --node " . $node->id . $debugFlag . "";
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Pterodactyl\Models\Daemons;
use Illuminate\Support\Str;
use Pterodactyl\Models\Node;
use Illuminate\Container\Container;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Contracts\Daemon\Daemon;
class Wings implements Daemon
{
public function getConfiguration(Node $node): array
{
return [
'debug' => false,
'uuid' => $node->uuid,
'token_id' => $node->daemon_token_id,
'token' => Container::getInstance()->make(Encrypter::class)->decrypt($node->daemon_token),
'api' => [
'host' => '0.0.0.0',
'port' => $node->daemonListen,
'ssl' => [
'enabled' => (!$node->behind_proxy && $node->scheme === 'https'),
'cert' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/fullchain.pem',
'key' => '/etc/letsencrypt/live/' . Str::lower($node->getInternalFqdn()) . '/privkey.pem',
],
'upload_limit' => $node->upload_size,
],
'system' => [
'data' => $node->daemonBase,
'sftp' => [
'bind_port' => $node->daemonSFTP,
],
],
'allowed_mounts' => $node->mounts->pluck('source')->toArray(),
'remote' => route('index'),
'allowed_origins' => [
config('app.url'),
],
];
}
public function getAutoDeploy(Node $node, string $token): string
{
$debugFlag = config('app.debug') ? ' --allow-insecure' : '';
return "cd /etc/pterodactyl && sudo wings configure --panel-url " . config('app.url') . " --token " . $token . " --node " . $node->id . $debugFlag . "";
}
}

View File

@@ -2,15 +2,18 @@
namespace Pterodactyl\Models;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Enums\Daemon\DaemonType;
use Illuminate\Notifications\Notifiable;
use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Pterodactyl\Contracts\Daemon\Daemon as DaemonInterface;
use Pterodactyl\Http\Controllers\Admin\NodeAutoDeployController;
/**
* @property int $id
@@ -36,6 +39,8 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
* @property int $daemonListen
* @property int $daemonSFTP
* @property string $daemonBase
* @property string $daemonType
* @property string $backupDisk
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property Location $location
@@ -43,6 +48,7 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough;
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
*/
class Node extends Model
{
/** @use HasFactory<\Database\Factories\NodeFactory> */
@@ -110,6 +116,8 @@ class Node extends Model
'daemon_token',
'description',
'maintenance_mode',
'daemonType',
'backupDisk'
];
public static array $validationRules = [
@@ -132,6 +140,8 @@ class Node extends Model
'daemonListen' => 'required|numeric|between:1,65535',
'maintenance_mode' => 'boolean',
'upload_size' => 'int|between:1,1024',
'daemonType' => 'required|string',
'backupDisk' => 'required|string'
];
/**
@@ -150,6 +160,23 @@ class Node extends Model
'use_separate_fqdns' => false,
];
private function getDaemonImplementation(): DaemonInterface
{
$implementations = DaemonType::allClass();
$daemonType = strtolower($this->daemonType);
if (!isset($implementations[$daemonType])) {
return new \Pterodactyl\Models\Daemons\Elytra();
}
$implementationClass = $implementations[$daemonType];
return new $implementationClass();
}
/**
* Get the connection address to use when making calls to this node.
* This will use the internal FQDN if separate FQDNs are enabled and internal_fqdn is set,
@@ -188,80 +215,17 @@ class Node extends Model
*/
public function getConfiguration(): array
{
return [
'debug' => false,
'uuid' => $this->uuid,
'token_id' => $this->daemon_token_id,
'token' => Container::getInstance()->make(Encrypter::class)->decrypt($this->daemon_token),
'api' => [
'host' => '0.0.0.0',
'port' => $this->daemonListen,
'ssl' => [
'enabled' => (!$this->behind_proxy && $this->scheme === 'https'),
'cert' => '/etc/letsencrypt/live/' . Str::lower($this->getInternalFqdn()) . '/fullchain.pem',
'key' => '/etc/letsencrypt/live/' . Str::lower($this->getInternalFqdn()) . '/privkey.pem',
],
'upload_limit' => $this->upload_size,
],
'system' => [
'data' => $this->daemonBase,
'sftp' => [
'bind_port' => $this->daemonSFTP,
],
'backups' => [
'rustic' => $this->getRusticBackupConfiguration(),
],
],
'allowed_mounts' => $this->mounts->pluck('source')->toArray(),
'remote' => route('index'),
'allowed_origins' => [
config('app.url'), // note: I have no idea why this wasn't included by Pterodactyl upstream, this might need to be configurable later - ellie
],
];
$daemon = $this->getDaemonImplementation();
return $daemon->getConfiguration($this);
}
/**
* Get rustic backup configuration for Wings.
* Matches the exact structure expected by elytra rustic implementation.
* Returns the auto deploy command as a string.
*/
private function getRusticBackupConfiguration(): array
public function getAutoDeploy(string $token): string
{
$localConfig = config('backups.disks.rustic_local', []);
$s3Config = config('backups.disks.rustic_s3', []);
return [
// Path to rustic binary
'binary_path' => $localConfig['binary_path'] ?? 'rustic',
// Repository version (optional, default handled by rustic)
'repository_version' => $localConfig['repository_version'] ?? 2,
// Pack size configuration for performance tuning
'tree_pack_size_mb' => $localConfig['tree_pack_size_mb'] ?? 4,
'data_pack_size_mb' => $localConfig['data_pack_size_mb'] ?? 32,
// Local repository configuration
'local' => [
'enabled' => !empty($localConfig),
'repository_path' => $localConfig['repository_path'] ?? '/var/lib/pterodactyl/rustic-repos',
'use_cold_storage' => $localConfig['use_cold_storage'] ?? false,
'hot_repository_path' => $localConfig['hot_repository_path'] ?? '',
],
// S3 repository configuration
's3' => [
'enabled' => !empty($s3Config['bucket']),
'endpoint' => $s3Config['endpoint'] ?? '',
'region' => $s3Config['region'] ?? 'us-east-1',
'bucket' => $s3Config['bucket'] ?? '',
'use_cold_storage' => $s3Config['use_cold_storage'] ?? false,
'hot_bucket' => $s3Config['hot_bucket'] ?? '',
'cold_storage_class' => $s3Config['cold_storage_class'] ?? 'GLACIER',
'force_path_style' => $s3Config['force_path_style'] ?? false,
'disable_ssl' => $s3Config['disable_ssl'] ?? false,
'ca_cert_path' => $s3Config['ca_cert_path'] ?? '',
],
];
$daemon = $this->getDaemonImplementation();
return $daemon->getAutoDeploy($this, $token);
}
/**

View File

@@ -6,234 +6,234 @@ use Illuminate\Support\Collection;
class Permission extends Model
{
/**
* The resource name for this model when it is transformed into an
* API representation using fractal.
*/
public const RESOURCE_NAME = 'subuser_permission';
/**
* The resource name for this model when it is transformed into an
* API representation using fractal.
*/
public const RESOURCE_NAME = 'subuser_permission';
/**
* Constants defining different permissions available.
*/
public const ACTION_WEBSOCKET_CONNECT = 'websocket.connect';
public const ACTION_CONTROL_CONSOLE = 'control.console';
public const ACTION_CONTROL_START = 'control.start';
public const ACTION_CONTROL_STOP = 'control.stop';
public const ACTION_CONTROL_RESTART = 'control.restart';
/**
* Constants defining different permissions available.
*/
public const ACTION_WEBSOCKET_CONNECT = 'websocket.connect';
public const ACTION_CONTROL_CONSOLE = 'control.console';
public const ACTION_CONTROL_START = 'control.start';
public const ACTION_CONTROL_STOP = 'control.stop';
public const ACTION_CONTROL_RESTART = 'control.restart';
public const ACTION_DATABASE_READ = 'database.read';
public const ACTION_DATABASE_CREATE = 'database.create';
public const ACTION_DATABASE_UPDATE = 'database.update';
public const ACTION_DATABASE_DELETE = 'database.delete';
public const ACTION_DATABASE_VIEW_PASSWORD = 'database.view_password';
public const ACTION_DATABASE_READ = 'database.read';
public const ACTION_DATABASE_CREATE = 'database.create';
public const ACTION_DATABASE_UPDATE = 'database.update';
public const ACTION_DATABASE_DELETE = 'database.delete';
public const ACTION_DATABASE_VIEW_PASSWORD = 'database.view_password';
public const ACTION_SCHEDULE_READ = 'schedule.read';
public const ACTION_SCHEDULE_CREATE = 'schedule.create';
public const ACTION_SCHEDULE_UPDATE = 'schedule.update';
public const ACTION_SCHEDULE_DELETE = 'schedule.delete';
public const ACTION_SCHEDULE_READ = 'schedule.read';
public const ACTION_SCHEDULE_CREATE = 'schedule.create';
public const ACTION_SCHEDULE_UPDATE = 'schedule.update';
public const ACTION_SCHEDULE_DELETE = 'schedule.delete';
public const ACTION_USER_READ = 'user.read';
public const ACTION_USER_CREATE = 'user.create';
public const ACTION_USER_UPDATE = 'user.update';
public const ACTION_USER_DELETE = 'user.delete';
public const ACTION_USER_READ = 'user.read';
public const ACTION_USER_CREATE = 'user.create';
public const ACTION_USER_UPDATE = 'user.update';
public const ACTION_USER_DELETE = 'user.delete';
public const ACTION_BACKUP_READ = 'backup.read';
public const ACTION_BACKUP_CREATE = 'backup.create';
public const ACTION_BACKUP_DELETE = 'backup.delete';
public const ACTION_BACKUP_DOWNLOAD = 'backup.download';
public const ACTION_BACKUP_RESTORE = 'backup.restore';
public const ACTION_BACKUP_READ = 'backup.read';
public const ACTION_BACKUP_CREATE = 'backup.create';
public const ACTION_BACKUP_DELETE = 'backup.delete';
public const ACTION_BACKUP_DOWNLOAD = 'backup.download';
public const ACTION_BACKUP_RESTORE = 'backup.restore';
public const ACTION_ALLOCATION_READ = 'allocation.read';
public const ACTION_ALLOCATION_CREATE = 'allocation.create';
public const ACTION_ALLOCATION_UPDATE = 'allocation.update';
public const ACTION_ALLOCATION_DELETE = 'allocation.delete';
public const ACTION_ALLOCATION_READ = 'allocation.read';
public const ACTION_ALLOCATION_CREATE = 'allocation.create';
public const ACTION_ALLOCATION_UPDATE = 'allocation.update';
public const ACTION_ALLOCATION_DELETE = 'allocation.delete';
public const ACTION_FILE_READ = 'file.read';
public const ACTION_FILE_READ_CONTENT = 'file.read-content';
public const ACTION_FILE_CREATE = 'file.create';
public const ACTION_FILE_UPDATE = 'file.update';
public const ACTION_FILE_DELETE = 'file.delete';
public const ACTION_FILE_ARCHIVE = 'file.archive';
public const ACTION_FILE_SFTP = 'file.sftp';
public const ACTION_FILE_READ = 'file.read';
public const ACTION_FILE_READ_CONTENT = 'file.read-content';
public const ACTION_FILE_CREATE = 'file.create';
public const ACTION_FILE_UPDATE = 'file.update';
public const ACTION_FILE_DELETE = 'file.delete';
public const ACTION_FILE_ARCHIVE = 'file.archive';
public const ACTION_FILE_SFTP = 'file.sftp';
public const ACTION_STARTUP_READ = 'startup.read';
public const ACTION_STARTUP_UPDATE = 'startup.update';
public const ACTION_STARTUP_COMMAND = 'startup.command';
public const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image';
public const ACTION_STARTUP_READ = 'startup.read';
public const ACTION_STARTUP_UPDATE = 'startup.update';
public const ACTION_STARTUP_COMMAND = 'startup.command';
public const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image';
public const ACTION_STARTUP_SOFTWARE = 'startup.software';
public const ACTION_STARTUP_SOFTWARE = 'startup.software';
public const ACTION_SETTINGS_RENAME = 'settings.rename';
public const ACTION_SETTINGS_MODRINTH = 'settings.modrinth';
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
public const ACTION_SETTINGS_RENAME = 'settings.rename';
public const ACTION_SETTINGS_MODR = 'settings.mod';
public const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
public const ACTION_ACTIVITY_READ = 'activity.read';
public const ACTION_ACTIVITY_READ = 'activity.read';
public const ACTION_MODRINTH_DOWNLOAD = 'modrinth.download';
public const ACTION_MOD_DOWNLOAD = 'mod.download';
/**
* Should timestamps be used on this model.
*/
public $timestamps = false;
/**
* Should timestamps be used on this model.
*/
public $timestamps = false;
/**
* The table associated with the model.
*/
protected $table = 'permissions';
/**
* The table associated with the model.
*/
protected $table = 'permissions';
/**
* Fields that are not mass assignable.
*/
protected $guarded = ['id', 'created_at', 'updated_at'];
/**
* Fields that are not mass assignable.
*/
protected $guarded = ['id', 'created_at', 'updated_at'];
/**
* Cast values to correct type.
*/
protected $casts = [
'subuser_id' => 'integer',
];
/**
* Cast values to correct type.
*/
protected $casts = [
'subuser_id' => 'integer',
];
public static array $validationRules = [
'subuser_id' => 'required|numeric|min:1',
'permission' => 'required|string',
];
public static array $validationRules = [
'subuser_id' => 'required|numeric|min:1',
'permission' => 'required|string',
];
/**
* All the permissions available on the system. You should use self::permissions()
* to retrieve them, and not directly access this array as it is subject to change.
*
* @see \Pterodactyl\Models\Permission::permissions()
*/
protected static array $permissions = [
'websocket' => [
'description' => 'Allows the user to connect to the server websocket, giving them access to view console output and realtime server stats.',
'keys' => [
'connect' => 'Allows a user to connect to the websocket instance for a server to stream the console.',
],
],
/**
* All the permissions available on the system. You should use self::permissions()
* to retrieve them, and not directly access this array as it is subject to change.
*
* @see \Pterodactyl\Models\Permission::permissions()
*/
protected static array $permissions = [
'websocket' => [
'description' => 'Allows the user to connect to the server websocket, giving them access to view console output and realtime server stats.',
'keys' => [
'connect' => 'Allows a user to connect to the websocket instance for a server to stream the console.',
],
],
'control' => [
'description' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.',
'keys' => [
'console' => 'Allows a user to send commands to the server instance via the console.',
'start' => 'Allows a user to start the server if it is stopped.',
'stop' => 'Allows a user to stop a server if it is running.',
'restart' => 'Allows a user to perform a server restart. This allows them to start the server if it is offline, but not put the server in a completely stopped state.',
],
],
'control' => [
'description' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.',
'keys' => [
'console' => 'Allows a user to send commands to the server instance via the console.',
'start' => 'Allows a user to start the server if it is stopped.',
'stop' => 'Allows a user to stop a server if it is running.',
'restart' => 'Allows a user to perform a server restart. This allows them to start the server if it is offline, but not put the server in a completely stopped state.',
],
],
'user' => [
'description' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.',
'keys' => [
'create' => 'Allows a user to create new subusers for the server.',
'read' => 'Allows the user to view subusers and their permissions for the server.',
'update' => 'Allows a user to modify other subusers.',
'delete' => 'Allows a user to delete a subuser from the server.',
],
],
'user' => [
'description' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.',
'keys' => [
'create' => 'Allows a user to create new subusers for the server.',
'read' => 'Allows the user to view subusers and their permissions for the server.',
'update' => 'Allows a user to modify other subusers.',
'delete' => 'Allows a user to delete a subuser from the server.',
],
],
'file' => [
'description' => 'Permissions that control a user\'s ability to modify the filesystem for this server.',
'keys' => [
'create' => 'Allows a user to create additional files and folders via the Panel or direct upload.',
'read' => 'Allows a user to view the contents of a directory, but not view the contents of or download files.',
'read-content' => 'Allows a user to view the contents of a given file. This will also allow the user to download files.',
'update' => 'Allows a user to update the contents of an existing file or directory.',
'delete' => 'Allows a user to delete files or directories.',
'archive' => 'Allows a user to archive the contents of a directory as well as decompress existing archives on the system.',
'sftp' => 'Allows a user to connect to SFTP and manage server files using the other assigned file permissions.',
],
],
'file' => [
'description' => 'Permissions that control a user\'s ability to modify the filesystem for this server.',
'keys' => [
'create' => 'Allows a user to create additional files and folders via the Panel or direct upload.',
'read' => 'Allows a user to view the contents of a directory, but not view the contents of or download files.',
'read-content' => 'Allows a user to view the contents of a given file. This will also allow the user to download files.',
'update' => 'Allows a user to update the contents of an existing file or directory.',
'delete' => 'Allows a user to delete files or directories.',
'archive' => 'Allows a user to archive the contents of a directory as well as decompress existing archives on the system.',
'sftp' => 'Allows a user to connect to SFTP and manage server files using the other assigned file permissions.',
],
],
'backup' => [
'description' => 'Permissions that control a user\'s ability to generate and manage server backups.',
'keys' => [
'create' => 'Allows a user to create new backups for this server.',
'read' => 'Allows a user to view all backups that exist for this server.',
'delete' => 'Allows a user to remove backups from the system.',
'download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.',
'restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.',
],
],
'backup' => [
'description' => 'Permissions that control a user\'s ability to generate and manage server backups.',
'keys' => [
'create' => 'Allows a user to create new backups for this server.',
'read' => 'Allows a user to view all backups that exist for this server.',
'delete' => 'Allows a user to remove backups from the system.',
'download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.',
'restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.',
],
],
// Controls permissions for editing or viewing a server's allocations.
'allocation' => [
'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
'keys' => [
'read' => 'Allows a user to view all allocations currently assigned to this server. Users with any level of access to this server can always view the primary allocation.',
'create' => 'Allows a user to assign additional allocations to the server.',
'update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.',
'delete' => 'Allows a user to delete an allocation from the server.',
],
],
// Controls permissions for editing or viewing a server's allocations.
'allocation' => [
'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
'keys' => [
'read' => 'Allows a user to view all allocations currently assigned to this server. Users with any level of access to this server can always view the primary allocation.',
'create' => 'Allows a user to assign additional allocations to the server.',
'update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.',
'delete' => 'Allows a user to delete an allocation from the server.',
],
],
// Controls permissions for editing or viewing a server's startup parameters.
'startup' => [
'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'keys' => [
'read' => 'Allows a user to view the startup variables for a server.',
'update' => 'Allows a user to modify the startup variables for the server.',
'command' => 'Allows a user to modify the startup command for the server.',
'docker-image' => 'Allows a user to modify the Docker image used when running the server.',
'software' => 'Allows a user to modify the game / software used for the server.',
],
],
// Controls permissions for editing or viewing a server's startup parameters.
'startup' => [
'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'keys' => [
'read' => 'Allows a user to view the startup variables for a server.',
'update' => 'Allows a user to modify the startup variables for the server.',
'command' => 'Allows a user to modify the startup command for the server.',
'docker-image' => 'Allows a user to modify the Docker image used when running the server.',
'software' => 'Allows a user to modify the game / software used for the server.',
],
],
'database' => [
'description' => 'Permissions that control a user\'s access to the database management for this server.',
'keys' => [
'create' => 'Allows a user to create a new database for this server.',
'read' => 'Allows a user to view the database associated with this server.',
'update' => 'Allows a user to rotate the password on a database instance. If the user does not have the view_password permission they will not see the updated password.',
'delete' => 'Allows a user to remove a database instance from this server.',
'view_password' => 'Allows a user to view the password associated with a database instance for this server.',
],
],
'database' => [
'description' => 'Permissions that control a user\'s access to the database management for this server.',
'keys' => [
'create' => 'Allows a user to create a new database for this server.',
'read' => 'Allows a user to view the database associated with this server.',
'update' => 'Allows a user to rotate the password on a database instance. If the user does not have the view_password permission they will not see the updated password.',
'delete' => 'Allows a user to remove a database instance from this server.',
'view_password' => 'Allows a user to view the password associated with a database instance for this server.',
],
],
'schedule' => [
'description' => 'Permissions that control a user\'s access to the schedule management for this server.',
'keys' => [
'create' => 'Allows a user to create new schedules for this server.', // task.create-schedule
'read' => 'Allows a user to view schedules and the tasks associated with them for this server.', // task.view-schedule, task.list-schedules
'update' => 'Allows a user to update schedules and schedule tasks for this server.', // task.edit-schedule, task.queue-schedule, task.toggle-schedule
'delete' => 'Allows a user to delete schedules for this server.', // task.delete-schedule
],
],
'schedule' => [
'description' => 'Permissions that control a user\'s access to the schedule management for this server.',
'keys' => [
'create' => 'Allows a user to create new schedules for this server.', // task.create-schedule
'read' => 'Allows a user to view schedules and the tasks associated with them for this server.', // task.view-schedule, task.list-schedules
'update' => 'Allows a user to update schedules and schedule tasks for this server.', // task.edit-schedule, task.queue-schedule, task.toggle-schedule
'delete' => 'Allows a user to delete schedules for this server.', // task.delete-schedule
],
],
'settings' => [
'description' => 'Permissions that control a user\'s access to the settings for this server.',
'keys' => [
'rename' => 'Allows a user to rename this server and change the description of it.',
'reinstall' => 'Allows a user to trigger a reinstall of this server.',
'settings' => [
'description' => 'Permissions that control a user\'s access to the settings for this server.',
'keys' => [
'rename' => 'Allows a user to rename this server and change the description of it.',
'reinstall' => 'Allows a user to trigger a reinstall of this server.',
],
],
],
],
'activity' => [
'description' => 'Permissions that control a user\'s access to the server activity logs.',
'keys' => [
'read' => 'Allows a user to view the activity logs for the server.',
],
],
'activity' => [
'description' => 'Permissions that control a user\'s access to the server activity logs.',
'keys' => [
'read' => 'Allows a user to view the activity logs for the server.',
],
],
'modrinth' => [
'description' => 'Permissions that control a user\'s access to downloading and updating mods.',
'keys' => [
'version' => 'Allows a user to change what version to download for',
'loader' => 'Allows a user to change what loader to download for',
'download' => 'Allows a user to download mods to the server using modrinth',
'resolver' => 'Allows a user to access the Dependency Resolver',
'update' => 'Allows a user to update Currently installed mods',
],
],
];
'mod' => [
'description' => 'Permissions that control a user\'s access to downloading and updating mods.',
'keys' => [
'version' => 'Allows a user to change what version to download for',
'loader' => 'Allows a user to change what loader to download for',
'download' => 'Allows a user to download mods to the server',
'resolver' => 'Allows a user to access the Dependency Resolver',
'update' => 'Allows a user to update Currently installed mods',
],
],
];
/**
* Returns all the permissions available on the system for a user to
* have when controlling a server.
*/
public static function permissions(): Collection
{
return Collection::make(self::$permissions);
}
/**
* Returns all the permissions available on the system for a user to
* have when controlling a server.
*/
public static function permissions(): Collection
{
return Collection::make(self::$permissions);
}
}

View File

@@ -239,7 +239,7 @@ class Server extends Model
if (!$this->egg || !is_array($this->egg->docker_images) || empty($this->egg->docker_images)) {
return false;
}
return !in_array($this->image, array_values($this->egg->docker_images));
}
@@ -252,14 +252,14 @@ class Server extends Model
if (!$this->egg || !is_array($this->egg->docker_images) || empty($this->egg->docker_images)) {
throw new \RuntimeException('Server egg has no docker images configured.');
}
$eggDockerImages = $this->egg->docker_images;
$defaultImage = reset($eggDockerImages);
if (empty($defaultImage)) {
throw new \RuntimeException('Server egg has no valid default docker image.');
}
return $defaultImage;
}
@@ -530,7 +530,27 @@ class Server extends Model
if (
$this->isSuspended()
|| $this->node->isUnderMaintenance()
|| !$this->isInstalled()
/* || !$this->isInstalled() */
|| $this->status === self::STATUS_RESTORING_BACKUP
|| !is_null($this->transfer)
) {
throw new ServerStateConflictException($this);
}
}
/**
* Checks if the server is currently in a user-accessible state. If not, an
* exception is raised. This should be called whenever something needs to make
* sure the server is not in a weird state that should block user access.
*
* @throws ServerStateConflictException
*/
public function validateCurrentStateClient()
{
if (
$this->isSuspended()
|| $this->node->isUnderMaintenance()
/* || !$this->isInstalled() */ // NOTE: this causes issues with how users view servers with the new system
|| $this->status === self::STATUS_RESTORING_BACKUP
|| !is_null($this->transfer)
) {

View File

@@ -0,0 +1,45 @@
<?php
namespace Pterodactyl\Repositories\Eloquent;
use Carbon\Carbon;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BackupRepository extends EloquentRepository
{
public function model(): string
{
return Backup::class;
}
/**
* Determines if too many backups have been generated by the server.
*/
public function getBackupsGeneratedDuringTimespan(int $server, int $seconds = 600): array|Collection
{
return $this->getBuilder()
->withTrashed()
->where('server_id', $server)
->where(function ($query) {
$query->whereNull('completed_at')
->orWhere('is_successful', '=', true);
})
->where('created_at', '>=', Carbon::now()->subSeconds($seconds)->toDateTimeString())
->get()
->toBase();
}
/**
* Returns a query filtering only non-failed backups for a specific server.
*/
public function getNonFailedBackups(Server $server): HasMany
{
return $server->backups()->where(function ($query) {
$query->whereNull('completed_at')
->orWhere('is_successful', true);
});
}
}

View File

@@ -176,4 +176,16 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa
return $instance->first();
}
/**
* Returns a node with the given id with the Node's resource usage.
*/
public function getDaemonType(int $node_id): Node
{
$instance = $this->getBuilder()
->select(['nodes.daemonType'])
->where('nodes.id', $node_id);
return $instance->first();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonCommandRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonCommandRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonCommandRepository extends DaemonRepository
{
/**
* Sends a command or multiple commands to a running server instance.
*
* @throws DaemonConnectionException
*/
public function send(array|string $command): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/commands', $this->server->uuid),
[
'json' => ['commands' => is_array($command) ? $command : [$command]],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Pterodactyl\Models\Node;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonConfigurationRepository extends DaemonRepository
{
/**
* Returns system information from the wings instance.
*
* @throws DaemonConnectionException
*/
public function getSystemInformation(?int $version = null): array
{
try {
$response = $this->getHttpClient()->get('/api/system' . (!is_null($version) ? '?v=' . $version : ''));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
return json_decode($response->getBody()->__toString(), true);
}
/**
* Updates the configuration information for a daemon. Updates the information for
* this instance using a passed-in model. This allows us to change plenty of information
* in the model, and still use the old, pre-update model to actually make the HTTP request.
*
* @throws DaemonConnectionException
*/
public function update(Node $node): ResponseInterface
{
try {
return $this->getHttpClient()->post(
'/api/update',
['json' => $node->getConfiguration()]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Illuminate\Support\Arr;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonFileRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonFileRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonFileRepository extends DaemonRepository
{
/**
* Return the contents of a given file.
*
* @param int|null $notLargerThan the maximum content length in bytes
*
* @throws TransferException
* @throws FileSizeTooLargeException
* @throws DaemonConnectionException
*/
public function getContent(string $path, ?int $notLargerThan = null): string
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient()->get(
sprintf('/api/servers/%s/files/contents', $this->server->uuid),
[
'query' => ['file' => $path],
]
);
} catch (ClientException|TransferException $exception) {
throw new DaemonConnectionException($exception);
}
$length = (int) Arr::get($response->getHeader('Content-Length'), 0, 0);
if ($notLargerThan && $length > $notLargerThan) {
throw new FileSizeTooLargeException();
}
return $response->getBody()->__toString();
}
/**
* Save new contents to a given file. This works for both creating and updating
* a file.
*
* @throws DaemonConnectionException
*/
public function putContent(string $path, string $content): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/write', $this->server->uuid),
[
'query' => ['file' => $path],
'body' => $content,
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Return a directory listing for a given path.
*
* @throws DaemonConnectionException
*/
public function getDirectory(string $path): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient()->get(
sprintf('/api/servers/%s/files/list-directory', $this->server->uuid),
[
'query' => ['directory' => $path],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
return json_decode($response->getBody(), true);
}
/**
* Creates a new directory for the server in the given $path.
*
* @throws DaemonConnectionException
*/
public function createDirectory(string $name, string $path): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/create-directory', $this->server->uuid),
[
'json' => [
'name' => $name,
'path' => $path,
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Renames or moves a file on the remote machine.
*
* @throws DaemonConnectionException
*/
public function renameFiles(?string $root, array $files): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->put(
sprintf('/api/servers/%s/files/rename', $this->server->uuid),
[
'json' => [
'root' => $root ?? '/',
'files' => $files,
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Copy a given file and give it a unique name.
*
* @throws DaemonConnectionException
*/
public function copyFile(string $location): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/copy', $this->server->uuid),
[
'json' => [
'location' => $location,
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Delete a file or folder for the server.
*
* @throws DaemonConnectionException
*/
public function deleteFiles(?string $root, array $files): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/delete', $this->server->uuid),
[
'json' => [
'root' => $root ?? '/',
'files' => $files,
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Compress the given files or folders in the given root.
*
* @throws DaemonConnectionException
*/
public function compressFiles(?string $root, array $files): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/compress', $this->server->uuid),
[
'json' => [
'root' => $root ?? '/',
'files' => $files,
],
// Wait for up to 15 minutes for the archive to be completed when calling this endpoint
// since it will likely take quite awhile for large directories.
'timeout' => 60 * 15,
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
return json_decode($response->getBody(), true);
}
/**
* Decompresses a given archive file.
*
* @throws DaemonConnectionException
*/
public function decompressFile(?string $root, string $file): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/decompress', $this->server->uuid),
[
'json' => [
'root' => $root ?? '/',
'file' => $file,
],
// Wait for up to 15 minutes for the decompress to be completed when calling this endpoint
// since it will likely take quite awhile for large directories.
'timeout' => 60 * 15,
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Chmods the given files.
*
* @throws DaemonConnectionException
*/
public function chmodFiles(?string $root, array $files): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/chmod', $this->server->uuid),
[
'json' => [
'root' => $root ?? '/',
'files' => $files,
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Pulls a file from the given URL and saves it to the disk.
*
* @throws DaemonConnectionException
*/
public function pull(string $url, ?string $directory, array $params = []): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
$attributes = [
'url' => $url,
'root' => $directory ?? '/',
'file_name' => $params['filename'] ?? null,
'use_header' => $params['use_header'] ?? null,
'foreground' => $params['foreground'] ?? null,
];
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/pull', $this->server->uuid),
[
'json' => array_filter($attributes, fn ($value) => !is_null($value)),
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonPowerRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonPowerRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonPowerRepository extends DaemonRepository
{
/**
* Sends a power action to the server instance.
*
* @throws DaemonConnectionException
*/
public function send(string $action): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/power', $this->server->uuid),
['json' => ['action' => $action]]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use GuzzleHttp\Client;
use Pterodactyl\Models\Node;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Illuminate\Contracts\Foundation\Application;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonRepository setServer(\Pterodactyl\Models\Server $server)
*/
abstract class DaemonRepository
{
protected ?Server $server;
protected ?Node $node;
/**
* DaemonRepository constructor.
*/
public function __construct(protected Application $app)
{
}
/**
* Set the server model this request is stemming from.
*/
public function setServer(Server $server): self
{
$this->server = $server;
$this->setNode($this->server->node);
return $this;
}
/**
* Set the node model this request is stemming from.
*/
public function setNode(Node $node): self
{
$this->node = $node;
return $this;
}
/**
* Return an instance of the Guzzle HTTP Client to be used for requests.
*/
public function getHttpClient(array $headers = []): Client
{
Assert::isInstanceOf($this->node, Node::class);
return new Client([
'verify' => $this->app->environment('production'),
'base_uri' => $this->node->getConnectionAddress(),
'timeout' => config('pterodactyl.guzzle.timeout'),
'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'),
'headers' => array_merge($headers, [
'Authorization' => 'Bearer ' . $this->node->getDecryptedKey(),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]),
]);
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonServerRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonServerRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonServerRepository extends DaemonRepository
{
/**
* Returns details about a server from the Daemon instance.
*
* @throws DaemonConnectionException
*/
public function getDetails(): array
{
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient()->get(
sprintf('/api/servers/%s', $this->server->uuid)
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception, false);
}
return json_decode($response->getBody()->__toString(), true);
}
/**
* Creates a new server on the Wings daemon.
*
* @throws DaemonConnectionException
*/
public function create(bool $startOnCompletion = true): void
{
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->post('/api/servers', [
'json' => [
'uuid' => $this->server->uuid,
'start_on_completion' => $startOnCompletion,
],
]);
} catch (GuzzleException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Triggers a server sync on Wings.
*
* @throws DaemonConnectionException
*/
public function sync(): void
{
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->post("/api/servers/{$this->server->uuid}/sync");
} catch (GuzzleException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Delete a server from the daemon, forcibly if passed.
*
* @throws DaemonConnectionException
*/
public function delete(): void
{
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->delete('/api/servers/' . $this->server->uuid);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Reinstall a server on the daemon.
*
* @throws DaemonConnectionException
*/
public function reinstall(): void
{
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->post(sprintf(
'/api/servers/%s/reinstall',
$this->server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Requests the daemon to create a full archive of the server. Once the daemon is finished
* they will send a POST request to "/api/remote/servers/{uuid}/archive" with a boolean.
*
* @throws DaemonConnectionException
*/
public function requestArchive(): void
{
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->post(sprintf(
'/api/servers/%s/archive',
$this->server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Revokes a single user's JTI by using their ID. This is simply a helper function to
* make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted
* correctly and avoids any costly mistakes in the codebase.
*
* @throws DaemonConnectionException
*/
public function revokeUserJTI(int $id): void
{
Assert::isInstanceOf($this->server, Server::class);
$this->revokeJTIs([md5($id . $this->server->uuid)]);
}
/**
* Revokes an array of JWT JTI's by marking any token generated before the current time on
* the Wings instance as being invalid.
*
* @throws DaemonConnectionException
*/
protected function revokeJTIs(array $jtis): void
{
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()
->post(sprintf('/api/servers/%s/ws/deny', $this->server->uuid), [
'json' => ['jtis' => $jtis],
]);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Pterodactyl\Models\Node;
use Lcobucci\JWT\Token\Plain;
use GuzzleHttp\Exception\GuzzleException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonTransferRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonTransferRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonTransferRepository extends DaemonRepository
{
/**
* @throws DaemonConnectionException
*/
public function notify(Node $targetNode, Plain $token): void
{
try {
$this->getHttpClient()->post(sprintf('/api/servers/%s/transfer', $this->server->uuid), [
'json' => [
'server_id' => $this->server->uuid,
'url' => $targetNode->getConnectionAddress() . '/api/transfers',
'token' => 'Bearer ' . $token->toString(),
'server' => [
'uuid' => $this->server->uuid,
'start_on_completion' => false,
],
],
]);
} catch (GuzzleException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
/**
* @method \Pterodactyl\Repositories\Wings\DaemonBackupRepository setNode(\Pterodactyl\Models\Node $node)
* @method \Pterodactyl\Repositories\Wings\DaemonBackupRepository setServer(\Pterodactyl\Models\Server $server)
*/
class DaemonBackupRepository extends DaemonRepository
{
protected ?string $adapter;
/**
* Sets the backup adapter for this execution instance.
*/
public function setBackupAdapter(string $adapter): self
{
$this->adapter = $adapter;
return $this;
}
/**
* Tells the remote Daemon to begin generating a backup for the server.
*
* @throws DaemonConnectionException
*/
public function backup(Backup $backup): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/backup', $this->server->uuid),
[
'json' => [
'adapter' => $this->adapter ?? config('backups.default'),
'uuid' => $backup->uuid,
'ignore' => implode("\n", $backup->ignored_files),
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Sends a request to Wings to begin restoring a backup for a server.
*
* @throws DaemonConnectionException
*/
public function restore(Backup $backup, ?string $url = null, bool $truncate = false): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/backup/%s/restore', $this->server->uuid, $backup->uuid),
[
'json' => [
'adapter' => $backup->disk,
'truncate_directory' => $truncate,
'download_url' => $url ?? '',
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Deletes a backup from the daemon.
*
* @throws DaemonConnectionException
*/
public function delete(Backup $backup): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->delete(
sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup->uuid)
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -37,7 +37,7 @@ class DaemonFileRepository extends DaemonRepository
'query' => ['file' => $path],
]
);
} catch (ClientException|TransferException $exception) {
} catch (ClientException | TransferException $exception) {
throw new DaemonConnectionException($exception);
}
@@ -291,7 +291,7 @@ class DaemonFileRepository extends DaemonRepository
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/pull', $this->server->uuid),
[
'json' => array_filter($attributes, fn ($value) => !is_null($value)),
'json' => array_filter($attributes, fn($value) => !is_null($value)),
]
);
} catch (TransferException $exception) {

View File

@@ -21,9 +21,7 @@ abstract class DaemonRepository
/**
* DaemonRepository constructor.
*/
public function __construct(protected Application $app)
{
}
public function __construct(protected Application $app) {}
/**
* Set the server model this request is stemming from.

View File

@@ -0,0 +1,28 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DaemonRevocationRepository extends DaemonRepository
{
/**
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for
* the provided servers. If no servers are provided, the user is deauthorized on all
* servers on the instance.
*
* @param string[] $servers
*/
public function deauthorize(string $user, array $servers = []): void
{
try {
$this->getHttpClient()->post('/api/deauthorize-user', [
'json' => ['user' => $user, 'servers' => $servers],
]);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Pterodactyl\Services\Backups\Wings;
use Illuminate\Http\Response;
use Pterodactyl\Models\Backup;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DeleteBackupService
{
public function __construct(
private ConnectionInterface $connection,
private BackupManager $manager,
private DaemonBackupRepository $daemonBackupRepository,
) {}
/**
* Deletes a backup from the system. If the backup is stored in S3 a request
* will be made to delete that backup from the disk as well.
*
* @throws \Throwable
*/
public function handle(Backup $backup): void
{
// If the backup is marked as failed it can still be deleted, even if locked
// since the UI doesn't allow you to unlock a failed backup in the first place.
//
// I also don't really see any reason you'd have a locked, failed backup to keep
// around. The logic that updates the backup to the failed state will also remove
// the lock, so this condition should really never happen.
if ($backup->is_locked && ($backup->is_successful && !is_null($backup->completed_at))) {
throw new BackupLockedException();
}
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$this->deleteFromS3($backup);
return;
}
$this->connection->transaction(function () use ($backup) {
try {
$this->daemonBackupRepository->setServer($backup->server)->delete($backup);
} catch (DaemonConnectionException $exception) {
$previous = $exception->getPrevious();
// Don't fail the request if the Daemon responds with a 404, just assume the backup
// doesn't actually exist and remove its reference from the Panel as well.
if (!$previous instanceof ClientException || $previous->getResponse()->getStatusCode() !== Response::HTTP_NOT_FOUND) {
throw $exception;
}
}
$backup->delete();
});
}
/**
* Deletes a backup from an S3 disk.
*
* @throws \Throwable
*/
protected function deleteFromS3(Backup $backup): void
{
$this->connection->transaction(function () use ($backup) {
$backup->delete();
/** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */
$adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3);
$adapter->getClient()->deleteObject([
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
]);
});
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Pterodactyl\Services\Backups\Wings;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Backup;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Extensions\Backups\BackupManager;
class DownloadLinkService
{
/**
* DownloadLinkService constructor.
*/
public function __construct(private BackupManager $backupManager, private NodeJWTService $jwtService) {}
/**
* Returns the URL that allows for a backup to be downloaded by an individual
* user, or by the Wings control software.
*/
public function handle(Backup $backup, User $user): string
{
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
return $this->getS3BackupUrl($backup);
}
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($user)
->setClaims([
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
])
->handle($backup->server->node, $user->id . $backup->server->uuid);
return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString());
}
/**
* Returns a signed URL that allows us to download a file directly out of a non-public
* S3 bucket by using a signed URL.
*/
protected function getS3BackupUrl(Backup $backup): string
{
/** @var \Pterodactyl\Extensions\Filesystem\S3Filesystem $adapter */
$adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3);
$request = $adapter->getClient()->createPresignedRequest(
$adapter->getClient()->getCommand('GetObject', [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
'ContentType' => 'application/x-gzip',
]),
CarbonImmutable::now()->addMinutes(5)
);
return $request->getUri()->__toString();
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Pterodactyl\Services\Backups\Wings;
use Ramsey\Uuid\Uuid;
use Carbon\CarbonImmutable;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
class InitiateBackupService
{
private ?array $ignoredFiles;
private bool $isLocked = false;
/**
* InitiateBackupService constructor.
*/
public function __construct(
private BackupRepository $repository,
private ConnectionInterface $connection,
private DaemonBackupRepository $daemonBackupRepository,
private DeleteBackupService $deleteBackupService,
private BackupManager $backupManager,
) {}
/**
* Set if the backup should be locked once it is created which will prevent
* its deletion by users or automated system processes.
*/
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
/**
* Sets the files to be ignored by this backup.
*
* @param string[]|null $ignored
*/
public function setIgnoredFiles(?array $ignored): self
{
if (is_array($ignored)) {
foreach ($ignored as $value) {
Assert::string($value);
}
}
// Set the ignored files to be any values that are not empty in the array. Don't use
// the PHP empty function here incase anything that is "empty" by default (0, false, etc.)
// were passed as a file or folder name.
$this->ignoredFiles = is_null($ignored) ? [] : array_filter($ignored, function ($value) {
return strlen($value) > 0;
});
return $this;
}
/**
* Initiates the backup process for a server on Wings.
*
* @throws \Throwable
* @throws TooManyBackupsException
* @throws TooManyRequestsHttpException
*/
public function handle(Server $server, ?string $name = null, bool $override = false): Backup
{
$limit = config('backups.throttles.limit');
$period = config('backups.throttles.period');
if ($period > 0) {
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
if ($previous->count() >= $limit) {
$message = sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period);
throw new TooManyRequestsHttpException((int) CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), $message);
}
}
// Check if the server has reached or exceeded its backup limit.
// completed_at == null will cover any ongoing backups, while is_successful == true will cover any completed backups.
$successful = $this->repository->getNonFailedBackups($server);
if ($server->backup_limit == null) {
$server->backup_limit = $successful->count() + 1;
};
if (!$server->backup_limit || $successful->count() >= $server->backup_limit) {
// Do not allow the user to continue if this server is already at its limit and can't override.
if ($server->backup_limit == null) {
$server->backup_limit = 12;
};
if (!$override || $server->backup_limit <= 0) {
throw new TooManyBackupsException($server->backup_limit);
}
// Get the oldest backup the server has that is not "locked" (indicating a backup that should
// never be automatically purged). If we find a backup we will delete it and then continue with
// this process. If no backup is found that can be used an exception is thrown.
/** @var Backup $oldest */
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
if (!$oldest) {
throw new TooManyBackupsException($server->backup_limit);
}
$this->deleteBackupService->handle($oldest);
}
return $this->connection->transaction(function () use ($server, $name) {
/** @var Backup $backup */
$backup = $this->repository->create([
'server_id' => $server->id,
'uuid' => Uuid::uuid4()->toString(),
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
'ignored_files' => array_values($this->ignoredFiles ?? []),
'disk' => $server->node->backupDisk,
'is_locked' => $this->isLocked,
], true, true);
$this->daemonBackupRepository->setServer($server)
->setBackupAdapter($server->node->backupDisk)
->backup($backup);
return $backup;
});
}
}

View File

@@ -2,6 +2,7 @@
namespace Pterodactyl\Services\Captcha;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Manager;
use Pterodactyl\Services\Captcha\Providers\TurnstileProvider;
use Pterodactyl\Services\Captcha\Providers\HCaptchaProvider;
@@ -73,6 +74,8 @@ class CaptchaManager extends Manager
*/
public function getWidget(): string
{
if ($this->getDefaultDriver() === 'none') {
return '';
}
@@ -103,4 +106,4 @@ class CaptchaManager extends Manager
return $this->driver()->getScriptIncludes();
}
}
}

View File

@@ -9,10 +9,11 @@ use Illuminate\Support\Facades\Log;
class RecaptchaProvider implements CaptchaProviderInterface
{
private const VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
/* private const VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; */
protected string $siteKey;
protected string $secretKey;
protected string $verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
public function __construct(array $config)
{
@@ -29,7 +30,7 @@ class RecaptchaProvider implements CaptchaProviderInterface
try {
$httpResponse = Http::timeout(10)
->asForm()
->post(self::VERIFY_URL, [
->post($this->verifyUrl, [
'secret' => $this->secretKey,
'response' => $response,
'remoteip' => $remoteIp,
@@ -106,4 +107,5 @@ class RecaptchaProvider implements CaptchaProviderInterface
{
return !empty($this->siteKey) && !empty($this->secretKey);
}
}
}

View File

@@ -372,13 +372,9 @@ class BackupJob implements Job
}
}
private function handleRestoreCompletion(ElytraJob $job, array $statusData): void
{
}
private function handleRestoreCompletion(ElytraJob $job, array $statusData): void {}
private function handleDownloadCompletion(ElytraJob $job, array $statusData): void
{
}
private function handleDownloadCompletion(ElytraJob $job, array $statusData): void {}
private function submitDeleteAllJob(Server $server, ElytraJob $job, ElytraRepository $elytraRepository): string
{
@@ -576,4 +572,5 @@ class BackupJob implements Job
{
return 'Backup operation failed. Please contact an administrator for details.'; // todo: better sanitization - elllie
}
}
}

View File

@@ -8,16 +8,10 @@ use Pterodactyl\Models\ServerSubdomain;
use Pterodactyl\Contracts\Dns\DnsProviderInterface;
use Pterodactyl\Contracts\Subdomain\SubdomainFeatureInterface;
use Pterodactyl\Exceptions\Dns\DnsProviderException;
use Pterodactyl\Services\Subdomain\Features\FactorioSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\MinecraftSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\RustSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\ScpSlSubdomainFeature;
use Pterodactyl\Services\Subdomain\Features\TeamSpeakSubdomainFeature;
use Pterodactyl\Services\Dns\Providers\CloudflareProvider;
use Pterodactyl\Services\Dns\Providers\HetznerProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Pterodactyl\Services\Dns\Providers\Route53Provider;
use Pterodactyl\Enums\Subdomain\Providers;
use Pterodactyl\Enums\Subdomain\Features;
class SubdomainManagementService
{
@@ -27,20 +21,10 @@ class SubdomainManagementService
public function __construct()
{
// Register DNS providers
$this->dnsProviders = [
'cloudflare' => CloudflareProvider::class,
'hetzner' => HetznerProvider::class,
'route53' => Route53Provider::class
];
$this->dnsProviders = Providers::all();
// Register subdomain features
$this->subdomainFeatures = [
'subdomain_factorio' => FactorioSubdomainFeature::class,
'subdomain_minecraft' => MinecraftSubdomainFeature::class,
'subdomain_rust' => RustSubdomainFeature::class,
'subdomain_scpsl' => ScpSlSubdomainFeature::class,
'subdomain_teamspeak' => TeamSpeakSubdomainFeature::class,
];
$this->subdomainFeatures = Features::all();
}
/**

View File

@@ -16,136 +16,138 @@ use Pterodactyl\Services\Servers\StartupCommandService;
class ServerTransformer extends BaseClientTransformer
{
protected array $defaultIncludes = ['allocations', 'variables'];
protected array $defaultIncludes = ['allocations', 'variables'];
protected array $availableIncludes = ['egg', 'subusers'];
protected array $availableIncludes = ['egg', 'subusers'];
public function getResourceName(): string
{
return Server::RESOURCE_NAME;
}
/**
* Transform a server model into a representation that can be returned
* to a client.
*/
public function transform(Server $server): array
{
/** @var StartupCommandService $service */
$service = Container::getInstance()->make(StartupCommandService::class);
$user = $this->request->user();
return [
'server_owner' => $user->id === $server->owner_id,
'identifier' => $server->uuidShort,
'internal_id' => $server->id,
'uuid' => $server->uuid,
'name' => $server->name,
'node' => $server->node->name,
'is_node_under_maintenance' => $server->node->isUnderMaintenance(),
'sftp_details' => [
'ip' => $server->node->fqdn,
'port' => $server->node->daemonSFTP,
],
'sftp_alias' => [
'ip' => $server->node->SFTPAliasAddress,
'port' => $server->node->SFTPAliasPort
],
'description' => $server->description,
'limits' => [
'memory' => $server->memory,
'overhead_memory' => $server->overhead_memory,
'swap' => $server->swap,
'disk' => $server->disk,
'io' => $server->io,
'cpu' => $server->cpu,
'threads' => $server->threads,
'oom_disabled' => $server->oom_disabled,
],
'invocation' => $service->handle($server, !$user->can(Permission::ACTION_STARTUP_READ, $server)),
'docker_image' => $server->image,
'egg_features' => $server->egg->inherit_features,
'egg' => $server->egg->uuid,
'feature_limits' => [
'databases' => $server->database_limit,
'allocations' => $server->allocation_limit,
'backups' => $server->backup_limit,
'backupStorageMb' => $server->backup_storage_limit,
],
'status' => $server->status,
// This field is deprecated, please use "status".
'is_suspended' => $server->isSuspended(),
// This field is deprecated, please use "status".
'is_installing' => !$server->isInstalled(),
'is_transferring' => !is_null($server->transfer),
];
}
/**
* Returns the allocations associated with this server.
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeAllocations(Server $server): Collection
{
$transformer = $this->makeTransformer(AllocationTransformer::class);
$user = $this->request->user();
// While we include this permission, we do need to actually handle it slightly different here
// for the purpose of keeping things functionally working. If the user doesn't have read permissions
// for the allocations we'll only return the primary server allocation, and any notes associated
// with it will be hidden.
//
// This allows us to avoid too much permission regression, without also hiding information that
// is generally needed for the frontend to make sense when browsing or searching results.
if (!$user->can(Permission::ACTION_ALLOCATION_READ, $server)) {
$primary = clone $server->allocation;
$primary->notes = null;
return $this->collection([$primary], $transformer, Allocation::RESOURCE_NAME);
public function getResourceName(): string
{
return Server::RESOURCE_NAME;
}
return $this->collection($server->allocations, $transformer, Allocation::RESOURCE_NAME);
}
/**
* Transform a server model into a representation that can be returned
* to a client.
*/
public function transform(Server $server): array
{
/** @var StartupCommandService $service */
$service = Container::getInstance()->make(StartupCommandService::class);
/**
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeVariables(Server $server): Collection|NullResource
{
if (!$this->request->user()->can(Permission::ACTION_STARTUP_READ, $server)) {
return $this->null();
$user = $this->request->user();
return [
'server_owner' => $user->id === $server->owner_id,
'identifier' => $server->uuidShort,
'internal_id' => $server->id,
'uuid' => $server->uuid,
'name' => $server->name,
'node' => $server->node->name,
'is_node_under_maintenance' => $server->node->isUnderMaintenance(),
'sftp_details' => [
'ip' => $server->node->fqdn,
'port' => $server->node->daemonSFTP,
],
'sftp_alias' => [
'ip' => $server->node->SFTPAliasAddress,
'port' => $server->node->SFTPAliasPort
],
'description' => $server->description,
'limits' => [
'memory' => $server->memory,
'overhead_memory' => $server->overhead_memory,
'swap' => $server->swap,
'disk' => $server->disk,
'io' => $server->io,
'cpu' => $server->cpu,
'threads' => $server->threads,
'oom_disabled' => $server->oom_disabled,
],
'invocation' => $service->handle($server, !$user->can(Permission::ACTION_STARTUP_READ, $server)),
'docker_image' => $server->image,
'egg_features' => $server->egg->inherit_features,
'egg' => $server->egg->uuid,
'feature_limits' => [
'databases' => $server->database_limit,
'allocations' => $server->allocation_limit,
'backups' => $server->backup_limit,
'backupStorageMb' => $server->backup_storage_limit,
],
'status' => $server->status,
// This field is deprecated, please use "status".
'is_suspended' => $server->isSuspended(),
// This field is deprecated, please use "status".
'is_installing' => !$server->isInstalled(),
'is_transferring' => !is_null($server->transfer),
'daemon_type' => $server->node->daemonType,
'backup_disk' => $server->node->backupDisk,
];
}
return $this->collection(
$server->variables->where('user_viewable', true),
$this->makeTransformer(EggVariableTransformer::class),
EggVariable::RESOURCE_NAME
);
}
/**
* Returns the allocations associated with this server.
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeAllocations(Server $server): Collection
{
$transformer = $this->makeTransformer(AllocationTransformer::class);
/**
* Returns the egg associated with this server.
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeEgg(Server $server): Item
{
return $this->item($server->egg, $this->makeTransformer(EggTransformer::class), Egg::RESOURCE_NAME);
}
$user = $this->request->user();
// While we include this permission, we do need to actually handle it slightly different here
// for the purpose of keeping things functionally working. If the user doesn't have read permissions
// for the allocations we'll only return the primary server allocation, and any notes associated
// with it will be hidden.
//
// This allows us to avoid too much permission regression, without also hiding information that
// is generally needed for the frontend to make sense when browsing or searching results.
if (!$user->can(Permission::ACTION_ALLOCATION_READ, $server)) {
$primary = clone $server->allocation;
$primary->notes = null;
/**
* Returns the subusers associated with this server.
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeSubusers(Server $server): Collection|NullResource
{
if (!$this->request->user()->can(Permission::ACTION_USER_READ, $server)) {
return $this->null();
return $this->collection([$primary], $transformer, Allocation::RESOURCE_NAME);
}
return $this->collection($server->allocations, $transformer, Allocation::RESOURCE_NAME);
}
return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME);
}
/**
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeVariables(Server $server): Collection|NullResource
{
if (!$this->request->user()->can(Permission::ACTION_STARTUP_READ, $server)) {
return $this->null();
}
return $this->collection(
$server->variables->where('user_viewable', true),
$this->makeTransformer(EggVariableTransformer::class),
EggVariable::RESOURCE_NAME
);
}
/**
* Returns the egg associated with this server.
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeEgg(Server $server): Item
{
return $this->item($server->egg, $this->makeTransformer(EggTransformer::class), Egg::RESOURCE_NAME);
}
/**
* Returns the subusers associated with this server.
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeSubusers(Server $server): Collection|NullResource
{
if (!$this->request->user()->can(Permission::ACTION_USER_READ, $server)) {
return $this->null();
}
return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME);
}
}

View File

@@ -72,13 +72,15 @@
],
"psr-4": {
"Pterodactyl\\": "app/",
"Pyrodactyl\\": "app/",
"Database\\Factories\\": "database/Factories/",
"Database\\Seeders\\": "database/Seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Pterodactyl\\Tests\\": "tests/"
"Pterodactyl\\Tests\\": "tests/",
"Pyrodactyl\\Tests\\": "tests/"
}
},
"scripts": {

View File

@@ -7,7 +7,7 @@ return [
// will be stored in this location by default. It is possible to change this once backups
// have been made, without losing data.
// Options: elytra, wings (legacy), s3, rustic_local, rustic_s3
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_RUSTIC_LOCAL),
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_WINGS),
// This value is used to determine the lifespan of UploadPart presigned urls that wings
// uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour.

View File

@@ -1,5 +1,5 @@
<?php
return [
'enabled' => env('CAPTCHA_ENABLED', false),
'enabled' => env('CAPTCHA_ENABLED', true),
];

View File

@@ -26,5 +26,5 @@ return [
| That way, you can access vars, like "SomeNamespace.someVariable."
|
*/
'js_namespace' => 'Pterodactyl',
'js_namespace' => 'Pyrodactyl',
];

View File

@@ -10,35 +10,37 @@ use Illuminate\Database\Eloquent\Factories\Factory;
class NodeFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Node::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'uuid' => Uuid::uuid4()->toString(),
'public' => true,
'name' => 'FactoryNode_' . Str::random(10),
'fqdn' => $this->faker->unique()->ipv4,
'scheme' => 'http',
'behind_proxy' => false,
'memory' => 1024,
'memory_overallocate' => 0,
'disk' => 10240,
'disk_overallocate' => 0,
'upload_size' => 100,
'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH),
'daemon_token' => Crypt::encrypt(Str::random(Node::DAEMON_TOKEN_LENGTH)),
'daemonListen' => 8080,
'daemonSFTP' => 2022,
'daemonBase' => '/var/lib/pterodactyl/volumes',
];
}
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Node::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'uuid' => Uuid::uuid4()->toString(),
'public' => true,
'name' => 'FactoryNode_' . Str::random(10),
'fqdn' => $this->faker->unique()->ipv4,
'scheme' => 'http',
'behind_proxy' => false,
'memory' => 1024,
'memory_overallocate' => 0,
'disk' => 10240,
'disk_overallocate' => 0,
'upload_size' => 100,
'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH),
'daemon_token' => Crypt::encrypt(Str::random(Node::DAEMON_TOKEN_LENGTH)),
'daemonListen' => 8080,
'daemonSFTP' => 2022,
'daemonBase' => '/var/lib/pterodactyl/volumes',
'backupDisk' => 'local',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table("nodes", function (Blueprint $table) {
$table->enum('daemonType', ['wings', 'elytra'])->default("elytra")->comment("What daemon Type this node uses");
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table("nodes", function (Blueprint $table) {
$table->dropColumn('daemonType');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
// omg, first migration of 2026
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table("nodes", function (Blueprint $table) {
$table->string('backupDisk')->default("rustic_local")->comment("What Backup type this Node uses");
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table("nodes", function (Blueprint $table) {
$table->dropColumn('backupDisk');
});
}
};

View File

@@ -1,6 +1,6 @@
{
"name": "pyrodactyl",
"version": "4.0.0-dev",
"version": "canary",
"buildNumber": "30",
"engines": {
"node": ">=20.0"
@@ -97,6 +97,7 @@
"postcss-import": "^16.1.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"rollup-plugin-visualizer": "^6.0.5",
"tailwind-scrollbar": "^4.0.2",
"ts-essentials": "^10.1.1",
"tw-animate-css": "^1.3.6",
@@ -123,4 +124,4 @@
"not dead"
],
"packageManager": "pnpm@10.13.1"
}
}

155
pnpm-lock.yaml generated
View File

@@ -273,6 +273,9 @@ importers:
prettier-plugin-tailwindcss:
specifier: ^0.6.14
version: 0.6.14(@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2))(prettier@3.6.2)
rollup-plugin-visualizer:
specifier: ^6.0.5
version: 6.0.5(rollup@4.46.2)
tailwind-scrollbar:
specifier: ^4.0.2
version: 4.0.2(react@19.1.1)(tailwindcss@4.1.11)
@@ -2120,6 +2123,10 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -2232,6 +2239,10 @@ packages:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -2331,6 +2342,10 @@ packages:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
@@ -2383,6 +2398,9 @@ packages:
electron-to-chromium@1.5.180:
resolution: {integrity: sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
enhanced-resolve@5.18.2:
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
engines: {node: '>=10.13.0'}
@@ -2623,6 +2641,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -2762,6 +2784,11 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -2770,6 +2797,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.0:
resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==}
engines: {node: '>= 0.4'}
@@ -2830,6 +2861,10 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -3092,6 +3127,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -3369,6 +3408,10 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -3386,6 +3429,19 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rollup-plugin-visualizer@6.0.5:
resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
rolldown: 1.x || ^1.0.0-beta
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.46.2:
resolution: {integrity: sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -3473,10 +3529,18 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -3496,6 +3560,10 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -3799,6 +3867,14 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -3806,6 +3882,14 @@ packages:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -5821,6 +5905,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -5970,6 +6056,12 @@ snapshots:
chownr@3.0.0: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
@@ -6065,6 +6157,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
define-lazy-prop@2.0.0: {}
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
@@ -6108,6 +6202,8 @@ snapshots:
electron-to-chromium@1.5.180: {}
emoji-regex@8.0.0: {}
enhanced-resolve@5.18.2:
dependencies:
graceful-fs: 4.2.11
@@ -6465,6 +6561,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -6603,12 +6701,16 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-docker@2.2.1: {}
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -6670,6 +6772,10 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -6898,6 +7004,12 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -7156,6 +7268,8 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
require-directory@2.1.1: {}
resolve-from@4.0.0: {}
resolve@1.22.10:
@@ -7172,6 +7286,15 @@ snapshots:
reusify@1.1.0: {}
rollup-plugin-visualizer@6.0.5(rollup@4.46.2):
dependencies:
open: 8.4.2
picomatch: 4.0.3
source-map: 0.7.6
yargs: 17.7.2
optionalDependencies:
rollup: 4.46.2
rollup@4.46.2:
dependencies:
'@types/estree': 1.0.8
@@ -7296,11 +7419,19 @@ snapshots:
source-map-js@1.2.1: {}
source-map@0.7.6: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
internal-slot: 1.1.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.8
@@ -7345,6 +7476,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-json-comments@3.1.1: {}
style-mod@4.1.2: {}
@@ -7636,10 +7771,30 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@5.0.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
yup@1.7.0:

View File

@@ -17,6 +17,7 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
$(document).ready(function() {
$('#pNestId').select2({
placeholder: 'Select a Nest',
@@ -44,7 +45,7 @@ $(document).ready(function() {
});
let lastActiveBox = null;
$(document).on('click', function (event) {
$(document).on('click', function(event) {
if (lastActiveBox !== null) {
lastActiveBox.removeClass('box-primary');
}
@@ -52,10 +53,10 @@ $(document).on('click', function (event) {
lastActiveBox = $(event.target).closest('.box');
lastActiveBox.addClass('box-primary');
});
$('#pNodeId').on('change', function () {
$('#pNodeId').on('change', function() {
currentNode = $(this).val();
$.each(Pterodactyl.nodeData, function (i, v) {
$.each(Pyrodactyl.nodeData, function(i, v) {
if (v.id == currentNode) {
$('#pAllocation').html('').select2({
data: v.allocations,
@@ -67,9 +68,10 @@ $('#pNodeId').on('change', function () {
});
});
$('#pNestId').on('change', function (event) {
$('#pNestId').on('change', function(event) {
const nestId = $(this).val();
$('#pEggId').html('').select2({
data: $.map(_.get(Pterodactyl.nests, $(this).val() + '.eggs', []), function (item) {
data: $.map(_.get(Pyrodactyl.nests, $(this).val() + '.eggs', []), function(item) {
return {
id: item.id,
text: item.name,
@@ -78,8 +80,8 @@ $('#pNestId').on('change', function (event) {
}).change();
});
$('#pEggId').on('change', function (event) {
let parentChain = _.get(Pterodactyl.nests, $('#pNestId').val(), null);
$('#pEggId').on('change', function(event) {
let parentChain = _.get(Pyrodactyl.nests, $('#pNestId').val(), null);
let objectChain = _.get(parentChain, 'eggs.' + $(this).val(), null);
const images = _.get(objectChain, 'docker_images', {})
@@ -100,7 +102,7 @@ $('#pEggId').on('change', function (event) {
$('#pPackId').html('').select2({
data: [{ id: 0, text: 'No Service Pack' }].concat(
$.map(_.get(objectChain, 'packs', []), function (item, i) {
$.map(_.get(objectChain, 'packs', []), function(item, i) {
return {
id: item.id,
text: item.name + ' (' + item.version + ')',
@@ -117,7 +119,7 @@ $('#pEggId').on('change', function (event) {
const variableIds = {};
$('#appendVariablesTo').html('');
$.each(_.get(objectChain, 'variables', []), function (i, item) {
$.each(_.get(objectChain, 'variables', []), function(i, item) {
variableIds[item.env_variable] = 'var_ref_' + item.id;
let isRequired = (item.required === 1) ? '<span class="label label-danger">Required</span> ' : '';
@@ -138,7 +140,7 @@ $('#pEggId').on('change', function (event) {
serviceVariablesUpdated($('#pEggId').val(), variableIds);
});
$('#pAllocation').on('change', function () {
$('#pAllocation').on('change', function() {
updateAdditionalAllocations();
});
@@ -146,7 +148,7 @@ function updateAdditionalAllocations() {
let currentAllocation = $('#pAllocation').val();
let currentNode = $('#pNodeId').val();
$.each(Pterodactyl.nodeData, function (i, v) {
$.each(Pyrodactyl.nodeData, function(i, v) {
if (v.id == currentNode) {
let allocations = [];
@@ -179,14 +181,14 @@ function initUserIdSelect(data) {
dataType: 'json',
delay: 250,
data: function (params) {
data: function(params) {
return {
filter: { email: params.term },
page: params.page,
};
},
processResults: function (data, params) {
processResults: function(data, params) {
return { results: data };
},
@@ -194,20 +196,20 @@ function initUserIdSelect(data) {
},
data: data,
escapeMarkup: function (markup) { return markup; },
escapeMarkup: function(markup) { return markup; },
minimumInputLength: 2,
templateResult: function (data) {
templateResult: function(data) {
if (data.loading) return escapeHtml(data.text);
return '<div class="user-block"> \
<img class="img-circle img-bordered-xs" src="https://www.gravatar.com/avatar/' + escapeHtml(data.md5) + '?s=120" alt="User Image"> \
<span class="username"> \
<a href="#">' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) +'</a> \
<a href="#">' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) + '</a> \
</span> \
<span class="description"><strong>' + escapeHtml(data.email) + '</strong> - ' + escapeHtml(data.username) + '</span> \
</div>';
},
templateSelection: function (data) {
templateSelection: function(data) {
return '<div> \
<span> \
<img class="img-rounded img-bordered-xs" src="https://www.gravatar.com/avatar/' + escapeHtml(data.md5) + '?s=120" style="height:28px;margin-top:-4px;" alt="User Image"> \

View File

@@ -1,3 +1,5 @@
// TODO: Convert this to pure React
// Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -17,8 +19,8 @@
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
$(document).ready(function () {
Socket.on('console', function (data) {
$(document).ready(function() {
Socket.on('console', function(data) {
if (typeof data === 'undefined' || typeof data.line === 'undefined') {
return;
}
@@ -37,7 +39,7 @@ $(document).ready(function () {
closeOnConfirm: false,
showLoaderOnConfirm: true,
},
function () {
function() {
$.ajax({
type: 'POST',
url: Pterodactyl.meta.saveFile,
@@ -47,7 +49,7 @@ $(document).ready(function () {
contents: 'eula=true',
},
})
.done(function (data) {
.done(function(data) {
$('[data-attr="power"][data-action="start"]').trigger('click');
swal({
type: 'success',
@@ -55,7 +57,7 @@ $(document).ready(function () {
text: 'The EULA for this server has been accepted, restarting server now.',
});
})
.fail(function (jqXHR) {
.fail(function(jqXHR) {
console.error(jqXHR);
swal({
title: 'Whoops!',

View File

@@ -7,6 +7,7 @@ import useSWR from 'swr';
import type { PaginatedResult, QueryBuilderParams } from '@/api/http';
import http, { withQueryBuilderParams } from '@/api/http';
import { getGlobalDaemonType } from '@/api/server/getServer';
import { ServerContext } from '@/state/server';
@@ -20,12 +21,13 @@ const useActivityLogs = (
config?: SWRConfiguration<PaginatedResult<ActivityLog>, AxiosError>,
) => {
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
const daemonType = getGlobalDaemonType();
const key = useServerSWRKey(['activity', useFilteredObject(filters || {})]);
return useSWR<PaginatedResult<ActivityLog>>(
key,
async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/activity`, {
const { data } = await http.get(`/api/client/servers/${daemonType}/${uuid}/activity`, {
params: {
...withQueryBuilderParams(filters),
include: ['actor'],

View File

@@ -1,4 +1,5 @@
import http from '@/api/http';
import { getGlobalDaemonType } from '@/api/server/getServer';
export interface ApplyEggChangeRequest {
egg_id: number;
@@ -21,6 +22,8 @@ export interface ApplyEggChangeResponse {
* This initiates a background operation to change the server's egg configuration.
*/
export default async (uuid: string, data: ApplyEggChangeRequest): Promise<ApplyEggChangeResponse> => {
const { data: response } = await http.post(`/api/client/servers/${uuid}/settings/egg/apply`, data);
const daemonType = getGlobalDaemonType();
const { data: response } = await http.post(`/api/client/servers/${daemonType}/${uuid}/settings/egg/apply`, data);
return response;
};

View File

@@ -0,0 +1,84 @@
import http from '@/api/http';
import { getGlobalDaemonType } from '@/api/server/getServer';
// NOTE: This is Specific to wings, it should also work for elytra, but I haven't actually tested
interface ApplyEggChangeData {
egg_id: number;
nest_id: number;
docker_image?: string;
startup_command?: string;
environment?: Record<string, string>;
should_backup?: boolean;
should_wipe?: boolean;
}
export default async (uuid: string, data: ApplyEggChangeData): Promise<void> => {
const daemonType = getGlobalDaemonType();
if (daemonType?.toLowerCase() === 'elytra') {
return http.post(`/api/client/servers/${daemonType}/${uuid}/settings/egg/apply`, data);
}
if (daemonType?.toLowerCase() === 'wings') {
const {
egg_id,
nest_id,
docker_image,
startup_command,
environment = {},
should_backup = false,
should_wipe = false,
} = data;
try {
await http.put(`/api/client/servers/${daemonType}/${uuid}/settings/egg`, {
egg_id,
nest_id,
});
if (docker_image) {
await http.put(`/api/client/servers/${daemonType}/${uuid}/settings/docker-image`, {
docker_image,
});
}
if (startup_command) {
console.warn('Custom startup command update not supported for Wings daemon - using egg default');
}
const envPromises = Object.entries(environment).map(([key, value]) =>
http.put(`/api/client/servers/${daemonType}/${uuid}/startup/variable`, {
key,
value,
}),
);
await Promise.all(envPromises);
if (should_backup) {
await http.post(`/api/client/servers/${daemonType}/${uuid}/backups`, {
name: `Software Change Backup - ${new Date().toISOString()}`,
is_locked: false,
});
}
if (should_wipe) {
const filesResponse = await http.get(
`/api/client/servers/${daemonType}/${uuid}/files/list?directory=/`,
);
const files = filesResponse.data?.data || [];
if (files.length > 0) {
const fileNames = files.map((file: any) => file.name);
await http.post(`/api/client/servers/${daemonType}/${uuid}/files/delete`, {
root: '/',
files: fileNames,
});
}
}
await http.post(`/api/client/servers/${daemonType}/${uuid}/settings/reinstall`);
} catch (error) {
console.error('Failed to apply egg change for Wings:', error);
throw error;
}
}
};

Some files were not shown because too many files have changed in this diff Show More