feature: deduplicated backups using elytra + backup storage quota + nullable server limits for unlimited (#359)

* feat: elytra integration with rustic

* fix: rustic deduplication

* fix: actually use snapshot id + don't guess type

* fix: adapter_type

* fix: wait no that's dumb

* fix: use `disk` you moron (me)

* fix: unique s3 repositories

* fix: path duplication

* fix: extra download verifications

* fix: proper s3 prefix

* feat: backup storage quota with deduplication + nullable limits

* fix: actually handle unlimited, no access, etc

* fix: more legacy code

* fix: better formatting of backup limits

* fix: snake_case lmao

* fix: caveman brain

* fix: don't try to estimate backup size

* Made backup usage metrics better

* fix: better results imo

---------

Co-authored-by: naterfute <me@naterfute.dev>
This commit is contained in:
Elizabeth
2025-09-21 12:00:15 -05:00
committed by GitHub
parent e7c634cc37
commit b347fb9dd6
41 changed files with 809 additions and 102 deletions

View File

@@ -9,10 +9,9 @@ class TooManyBackupsException extends DisplayException
/**
* TooManyBackupsException constructor.
*/
public function __construct(int $backupLimit)
public function __construct(int $backupLimit, ?string $customMessage = null)
{
parent::__construct(
sprintf('Cannot create a new backup, this server has reached its limit of %d backups.', $backupLimit)
);
$message = $customMessage ?? sprintf('Cannot create a new backup, this server has reached its limit of %d backups.', $backupLimit);
parent::__construct($message);
}
}

View File

@@ -122,6 +122,26 @@ class BackupManager
return new S3Filesystem($client, $config['bucket'], $config['prefix'] ?? '', $config['options'] ?? []);
}
/**
* Creates a new Rustic Local adapter.
* Rustic adapters don't use traditional filesystem operations - they are handled by Wings directly.
*/
public function createRusticLocalAdapter(array $config): FilesystemAdapter
{
// Return a minimal adapter since rustic operations are handled by Wings
return new InMemoryFilesystemAdapter();
}
/**
* Creates a new Rustic S3 adapter.
* Rustic adapters don't use traditional filesystem operations - they are handled by Wings directly.
*/
public function createRusticS3Adapter(array $config): FilesystemAdapter
{
// Return a minimal adapter since rustic operations are handled by Wings
return new InMemoryFilesystemAdapter();
}
/**
* Returns the configuration associated with a given backup type.
*/

View File

@@ -145,7 +145,7 @@ class ServersController extends Controller
$this->buildModificationService->handle($server, $request->only([
'allocation_id', 'add_allocations', 'remove_allocations',
'memory', 'overhead_memory', 'swap', 'io', 'cpu', 'threads', 'disk',
'database_limit', 'allocation_limit', 'backup_limit', 'oom_disabled',
'database_limit', 'allocation_limit', 'backup_limit', 'backup_storage_limit', 'oom_disabled',
'exclude_from_resource_calculation',
]));
} catch (DataValidationException $exception) {

View File

@@ -11,6 +11,7 @@ use Pterodactyl\Models\Permission;
use Illuminate\Auth\Access\AuthorizationException;
use Pterodactyl\Services\Backups\DeleteBackupService;
use Pterodactyl\Services\Backups\DownloadLinkService;
use Pterodactyl\Services\Backups\BackupStorageService;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Services\Backups\ServerStateService;
@@ -33,6 +34,7 @@ class BackupController extends ClientApiController
private DownloadLinkService $downloadLinkService,
private BackupRepository $repository,
private ServerStateService $serverStateService,
private BackupStorageService $backupStorageService,
) {
parent::__construct();
}
@@ -56,10 +58,26 @@ class BackupController extends ClientApiController
->orderByRaw('is_locked DESC, created_at DESC')
->paginate($limit);
$storageInfo = $this->backupStorageService->getStorageUsageInfo($server);
return $this->fractal->collection($backups)
->transformWith($this->getTransformer(BackupTransformer::class))
->addMeta([
'backup_count' => $this->repository->getNonFailedBackups($server)->count(),
'storage' => [
'used_mb' => $storageInfo['used_mb'],
'limit_mb' => $storageInfo['limit_mb'],
'has_limit' => $storageInfo['has_limit'],
'usage_percentage' => $storageInfo['usage_percentage'] ?? null,
'available_mb' => $storageInfo['available_mb'] ?? null,
'is_over_limit' => $storageInfo['is_over_limit'] ?? false,
],
'limits' => [
'count_limit' => $server->backup_limit,
'has_count_limit' => $server->hasBackupCountLimit(),
'storage_limit_mb' => $server->backup_storage_limit,
'has_storage_limit' => $server->hasBackupStorageLimit(),
],
])
->toArray();
}
@@ -88,7 +106,11 @@ class BackupController extends ClientApiController
Activity::event('server:backup.start')
->subject($backup)
->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')])
->property([
'name' => $backup->name,
'locked' => (bool) $request->input('is_locked'),
'adapter' => $backup->disk
])
->log();
return $this->fractal->item($backup)
@@ -212,7 +234,14 @@ class BackupController extends ClientApiController
throw new AuthorizationException();
}
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_WINGS) {
$allowedAdapters = [
Backup::ADAPTER_AWS_S3,
Backup::ADAPTER_WINGS,
Backup::ADAPTER_RUSTIC_LOCAL,
Backup::ADAPTER_RUSTIC_S3
];
if (!in_array($backup->disk, $allowedAdapters)) {
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
}
@@ -277,10 +306,10 @@ class BackupController extends ClientApiController
throw new BadRequestHttpException('Server state changed during restore initiation. Please try again.');
}
// 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 the backup is for an S3 file (legacy or rustic) we need to generate a unique
// Download link for it that will allow Wings to actually access the file.
$url = null;
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
if (in_array($backup->disk, [Backup::ADAPTER_AWS_S3, Backup::ADAPTER_RUSTIC_S3])) {
try {
$url = $this->downloadLinkService->handle($backup, $request->user());
} catch (\Exception $e) {

View File

@@ -92,7 +92,11 @@ class NetworkAllocationController extends ClientApiController
*/
public function store(NewAllocationRequest $request, Server $server): array
{
if ($server->allocations()->count() >= $server->allocation_limit) {
if (!$server->allowsAllocations()) {
throw new DisplayException('Cannot assign allocations to this server: allocations are disabled.');
}
if ($server->hasAllocationCountLimit() && $server->allocations()->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}

View File

@@ -44,8 +44,8 @@ class ScheduleTaskController extends ClientApiController
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.");
if (!$server->allowsBackups() && $request->action === 'backup') {
throw new HttpForbiddenException("A backup task cannot be created when backups are disabled for this server.");
}
/** @var Task|null $lastTask */
@@ -104,8 +104,8 @@ class ScheduleTaskController extends ClientApiController
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.");
if (!$server->allowsBackups() && $request->action === 'backup') {
throw new HttpForbiddenException("A backup task cannot be created when backups are disabled for this server.");
}
$this->connection->transaction(function () use ($request, $schedule, $task) {

View File

@@ -66,6 +66,7 @@ class BackupStatusController extends Controller
'is_locked' => $successful ? $model->is_locked : false,
'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null,
'bytes' => $successful ? $request->input('size') : 0,
'snapshot_id' => $successful ? $request->input('snapshot_id') : null,
'completed_at' => CarbonImmutable::now(),
])->save();
@@ -209,4 +210,5 @@ class BackupStatusController extends Controller
throw $e;
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote;
use Pterodactyl\Models\Server;
use Pterodactyl\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Crypt;
class RusticConfigController extends Controller
{
/**
* Get rustic configuration for a server.
* This endpoint is called by Wings to get the rustic backup configuration.
*/
public function show(Request $request, string $uuid): JsonResponse
{
$server = Server::where('uuid', $uuid)->firstOrFail();
$type = $request->query('type', 'local');
if (!in_array($type, ['local', 's3'])) {
return response()->json(['error' => 'Invalid backup type'], 400);
}
$config = [
'backup_type' => $type,
'repository_password' => $this->getRepositoryPassword($server),
'repository_path' => $this->getRepositoryPath($server, $type),
];
if ($type === 's3') {
$s3Credentials = $this->getS3Credentials($server);
if (!$s3Credentials) {
return response()->json(['error' => 'S3 credentials not configured for this server'], 400);
}
$config['s3_credentials'] = $s3Credentials;
}
return response()->json($config);
}
/**
* Generate server-specific repository password.
* This password is derived from the server UUID and application key for consistency.
*/
private function getRepositoryPassword(Server $server): string
{
// Use a deterministic approach: hash server UUID with app key
// This ensures the same password is generated each time for the same server
return hash('sha256', $server->uuid . config('app.key'));
}
/**
* Get server-specific repository path.
*/
private function getRepositoryPath(Server $server, string $type): string
{
if ($type === 'local') {
$basePath = config('backups.disks.rustic_local.repository_base_path', '/var/lib/pterodactyl/rustic-repos');
return rtrim($basePath, '/') . '/' . $server->uuid;
}
// For S3, return the actual S3 path that rustic will use
$s3Config = config('backups.disks.rustic_s3');
$bucket = $s3Config['bucket'] ?? '';
$prefix = rtrim($s3Config['prefix'] ?? 'pterodactyl-backups/', '/');
if (empty($bucket)) {
throw new \InvalidArgumentException('S3 bucket not configured for rustic backups');
}
// Return the full S3 path for this server's repository
return sprintf('%s/%s/%s', $bucket, $prefix, $server->uuid);
}
/**
* Get S3 credentials for rustic S3 backups from global configuration.
*/
private function getS3Credentials(Server $server): ?array
{
$config = config('backups.disks.rustic_s3');
if (empty($config['bucket'])) {
return null;
}
return [
'access_key_id' => $config['key'] ?? '',
'secret_access_key' => $config['secret'] ?? '',
'session_token' => '', // Not typically used for rustic
'region' => $config['region'] ?? 'us-east-1',
'bucket' => $config['bucket'],
'endpoint' => $config['endpoint'] ?? '',
'force_path_style' => $config['force_path_style'] ?? false,
'disable_ssl' => $config['disable_ssl'] ?? false,
'ca_cert_path' => $config['ca_cert_path'] ?? '',
];
}
}

View File

@@ -50,6 +50,7 @@ class StoreServerRequest extends ApplicationApiRequest
'feature_limits.databases' => $rules['database_limit'],
'feature_limits.allocations' => $rules['allocation_limit'],
'feature_limits.backups' => $rules['backup_limit'],
'feature_limits.backup_storage_mb' => $rules['backup_storage_limit'],
// Placeholders for rules added in withValidator() function.
'allocation.default' => '',
@@ -97,6 +98,7 @@ class StoreServerRequest extends ApplicationApiRequest
'database_limit' => array_get($data, 'feature_limits.databases'),
'allocation_limit' => array_get($data, 'feature_limits.allocations'),
'backup_limit' => array_get($data, 'feature_limits.backups'),
'backup_storage_limit' => array_get($data, 'feature_limits.backup_storage_mb'),
'oom_disabled' => array_get($data, 'oom_disabled'),
'exclude_from_resource_calculation' => array_get($data, 'exclude_from_resource_calculation', false),
];

View File

@@ -49,6 +49,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
'feature_limits.databases' => $rules['database_limit'],
'feature_limits.allocations' => $rules['allocation_limit'],
'feature_limits.backups' => $rules['backup_limit'],
'feature_limits.backup_storage_mb' => $rules['backup_storage_limit'],
];
}
@@ -63,6 +64,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
$data['database_limit'] = $data['feature_limits']['databases'] ?? null;
$data['allocation_limit'] = $data['feature_limits']['allocations'] ?? null;
$data['backup_limit'] = $data['feature_limits']['backups'] ?? null;
$data['backup_storage_limit'] = $data['feature_limits']['backup_storage_mb'] ?? null;
unset($data['allocation'], $data['feature_limits']);
// Adjust the limits field to match what is expected by the model.
@@ -90,6 +92,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
'feature_limits.databases' => 'Database Limit',
'feature_limits.allocations' => 'Allocation Limit',
'feature_limits.backups' => 'Backup Limit',
'feature_limits.backup_storage_mb' => 'Backup Storage Limit (MB)',
];
}

View File

@@ -16,6 +16,7 @@ class ReportBackupCompleteRequest extends FormRequest
'parts' => 'nullable|array',
'parts.*.etag' => 'required|string',
'parts.*.part_number' => 'required|numeric',
'snapshot_id' => 'nullable|string|max:64',
];
}
}

View File

@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
* @property string|null $checksum
* @property int $bytes
* @property string|null $upload_id
* @property string|null $snapshot_id
* @property \Carbon\CarbonImmutable|null $completed_at
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
@@ -36,6 +37,8 @@ class Backup extends Model
public const ADAPTER_WINGS = 'wings';
public const ADAPTER_AWS_S3 = 's3';
public const ADAPTER_RUSTIC_LOCAL = 'rustic_local';
public const ADAPTER_RUSTIC_S3 = 'rustic_s3';
protected $table = 'backups';
@@ -57,10 +60,47 @@ class Backup extends Model
'checksum' => null,
'bytes' => 0,
'upload_id' => null,
'snapshot_id' => null,
];
protected $guarded = ['id', 'created_at', 'updated_at', 'deleted_at'];
/**
* Check if this backup uses the rustic backup system.
*/
public function isRustic(): bool
{
return in_array($this->disk, [self::ADAPTER_RUSTIC_LOCAL, self::ADAPTER_RUSTIC_S3]);
}
/**
* Check if this backup is stored locally (not in cloud storage).
*/
public function isLocal(): bool
{
return in_array($this->disk, [self::ADAPTER_WINGS, self::ADAPTER_RUSTIC_LOCAL]);
}
/**
* Get the repository type for rustic backups.
*/
public function getRepositoryType(): ?string
{
return match($this->disk) {
self::ADAPTER_RUSTIC_LOCAL => 'local',
self::ADAPTER_RUSTIC_S3 => 's3',
default => null,
};
}
/**
* Check if this backup has a rustic snapshot ID.
*/
public function hasSnapshotId(): bool
{
return !empty($this->snapshot_id);
}
public static array $validationRules = [
'server_id' => 'bail|required|numeric|exists:servers,id',
'uuid' => 'required|uuid',
@@ -69,8 +109,9 @@ class Backup extends Model
'name' => 'required|string',
'ignored_files' => 'array',
'server_state' => 'nullable|array',
'disk' => 'required|string',
'disk' => 'required|string|in:wings,s3,rustic_local,rustic_s3',
'checksum' => 'nullable|string',
'snapshot_id' => 'nullable|string|max:64',
'bytes' => 'numeric',
'upload_id' => 'nullable|string',
];

View File

@@ -203,12 +203,60 @@ class Node extends Model
'sftp' => [
'bind_port' => $this->daemonSFTP,
],
'backups' => [
'rustic' => $this->getRusticBackupConfiguration(),
],
],
'allowed_mounts' => $this->mounts->pluck('source')->toArray(),
'remote' => route('index'),
];
}
/**
* Get rustic backup configuration for Wings.
* Matches the exact structure expected by elytra rustic implementation.
*/
private function getRusticBackupConfiguration(): array
{
$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'] ?? '',
'key_prefix' => $s3Config['prefix'] ?? 'pterodactyl-backups/',
'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'] ?? '',
],
];
}
/**
* Returns the configuration in Yaml format.
*/

View File

@@ -42,7 +42,8 @@ use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
* @property string $image
* @property int|null $allocation_limit
* @property int|null $database_limit
* @property int $backup_limit
* @property int|null $backup_limit
* @property int|null $backup_storage_limit
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $installed_at
@@ -173,9 +174,10 @@ class Server extends Model
'startup' => 'required|string',
'skip_scripts' => 'sometimes|boolean',
'image' => ['required', 'string', 'max:191', 'regex:/^~?[\w\.\/\-:@ ]*$/'],
'database_limit' => 'present|nullable|integer|min:0',
'allocation_limit' => 'sometimes|nullable|integer|min:0',
'backup_limit' => 'present|nullable|integer|min:0',
'database_limit' => 'nullable|integer|min:0',
'allocation_limit' => 'nullable|integer|min:0',
'backup_limit' => 'nullable|integer|min:0',
'backup_storage_limit' => 'nullable|integer|min:0',
];
/**
@@ -199,6 +201,7 @@ class Server extends Model
'database_limit' => 'integer',
'allocation_limit' => 'integer',
'backup_limit' => 'integer',
'backup_storage_limit' => 'integer',
self::CREATED_AT => 'datetime',
self::UPDATED_AT => 'datetime',
'deleted_at' => 'datetime',
@@ -372,6 +375,56 @@ class Server extends Model
return $this->hasMany(Backup::class);
}
/**
* Check if this server has a backup storage limit configured.
*/
public function hasBackupStorageLimit(): bool
{
return !is_null($this->backup_storage_limit) && $this->backup_storage_limit > 0;
}
/**
* Get the backup storage limit in bytes.
*/
public function getBackupStorageLimitBytes(): ?int
{
if (!$this->hasBackupStorageLimit()) {
return null;
}
return (int) ($this->backup_storage_limit * 1024 * 1024);
}
public function hasBackupCountLimit(): bool
{
return !is_null($this->backup_limit) && $this->backup_limit > 0;
}
public function allowsBackups(): bool
{
return is_null($this->backup_limit) || $this->backup_limit > 0;
}
public function hasDatabaseLimit(): bool
{
return !is_null($this->database_limit) && $this->database_limit > 0;
}
public function allowsDatabases(): bool
{
return is_null($this->database_limit) || $this->database_limit > 0;
}
public function hasAllocationLimit(): bool
{
return !is_null($this->allocation_limit) && $this->allocation_limit > 0;
}
public function allowsAllocations(): bool
{
return is_null($this->allocation_limit) || $this->allocation_limit > 0;
}
/**
* Returns all mounts that have this server has mounted.
*/

View File

@@ -0,0 +1,79 @@
<?php
namespace Pterodactyl\Services\Backups;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Backup;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
class BackupStorageService
{
public function __construct(
private BackupRepository $repository,
) {
}
public function calculateServerBackupStorage(Server $server): int
{
return $this->repository->getNonFailedBackups($server)->sum('bytes');
}
public function isOverStorageLimit(Server $server): bool
{
if (!$server->hasBackupStorageLimit()) {
return false;
}
return $this->calculateServerBackupStorage($server) > $server->getBackupStorageLimitBytes();
}
public function wouldExceedStorageLimit(Server $server, int $estimatedBackupSizeBytes): bool
{
if (!$server->hasBackupStorageLimit()) {
return false;
}
$currentUsage = $this->calculateServerBackupStorage($server);
$estimatedSize = $estimatedBackupSizeBytes * 0.5; // Conservative estimate for deduplication
return ($currentUsage + $estimatedSize) > $server->getBackupStorageLimitBytes();
}
public function getStorageUsageInfo(Server $server): array
{
$usedBytes = $this->calculateServerBackupStorage($server);
$limitBytes = $server->getBackupStorageLimitBytes();
$mbDivisor = 1024 * 1024;
$result = [
'used_bytes' => $usedBytes,
'used_mb' => round($usedBytes / $mbDivisor, 2),
'limit_bytes' => $limitBytes,
'limit_mb' => $server->backup_storage_limit,
'has_limit' => $server->hasBackupStorageLimit(),
];
if ($limitBytes) {
$result['usage_percentage'] = round(($usedBytes / $limitBytes) * 100, 1);
$result['available_bytes'] = max(0, $limitBytes - $usedBytes);
$result['available_mb'] = round($result['available_bytes'] / $mbDivisor, 2);
$result['is_over_limit'] = $usedBytes > $limitBytes;
}
return $result;
}
public function getBackupsForStorageCleanup(Server $server): \Illuminate\Database\Eloquent\Collection
{
return $this->repository->getNonFailedBackups($server)
->where('is_locked', false)
->sortBy('created_at');
}
public function calculateStorageFreedByDeletion(\Illuminate\Database\Eloquent\Collection $backups): int
{
return (int) $backups->sum(function ($backup) {
return $backup->isRustic() ? $backup->bytes * 0.3 : $backup->bytes;
});
}
}

View File

@@ -23,16 +23,24 @@ class DownloadLinkService
*/
public function handle(Backup $backup, User $user): string
{
// Validate backup can be downloaded
$this->validateBackupForDownload($backup);
// Legacy S3 backups use pre-signed URLs
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
return $this->getS3BackupUrl($backup);
}
// Wings local backups and Rustic backups (local & S3) use JWT tokens
// Wings handles rustic downloads internally by calling back to get rustic config
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser($user)
->setClaims([
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
'backup_disk' => $backup->disk,
'repository_type' => $backup->getRepositoryType(),
])
->handle($backup->server->node, $user->id . $backup->server->uuid);
@@ -59,4 +67,38 @@ class DownloadLinkService
return $request->getUri()->__toString();
}
/**
* Validates that a backup can be downloaded.
*/
protected function validateBackupForDownload(Backup $backup): void
{
// General backup validation
if (!$backup->is_successful) {
throw new \InvalidArgumentException('Cannot download a failed backup.');
}
if (is_null($backup->completed_at)) {
throw new \InvalidArgumentException('Cannot download backup that is still in progress.');
}
// Rustic-specific validation
if ($backup->isRustic()) {
if (!$backup->hasSnapshotId()) {
throw new \InvalidArgumentException('Rustic backup cannot be downloaded: missing snapshot ID.');
}
// Validate snapshot ID format
if (strlen($backup->snapshot_id) !== 64 && strlen($backup->snapshot_id) !== 8) {
throw new \InvalidArgumentException('Rustic backup has invalid snapshot ID format.');
}
}
// Legacy S3 backup validation
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
if ($backup->bytes <= 0) {
throw new \InvalidArgumentException('S3 backup has invalid size.');
}
}
}
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Services\Backups\BackupStorageService;
use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Carbon\CarbonImmutable;
@@ -20,6 +21,7 @@ class InitiateBackupService
private bool $isLocked = false;
/**
* InitiateBackupService constructor.
*/
@@ -30,6 +32,7 @@ class InitiateBackupService
private DeleteBackupService $deleteBackupService,
private BackupManager $backupManager,
private ServerStateService $serverStateService,
private BackupStorageService $backupStorageService,
) {
}
@@ -44,6 +47,7 @@ class InitiateBackupService
return $this;
}
/**
* Sets the files to be ignored by this backup.
*
@@ -85,19 +89,26 @@ class InitiateBackupService
throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.');
}
// 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 || $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 (!$override || $server->backup_limit <= 0) {
if (!$server->allowsBackups()) {
throw new TooManyBackupsException(0, 'Backups are disabled for this server');
}
// Block backup creation if already over storage limit
if ($server->hasBackupStorageLimit() && $this->backupStorageService->isOverStorageLimit($server)) {
$usage = $this->backupStorageService->getStorageUsageInfo($server);
throw new TooManyBackupsException(0, sprintf(
'Cannot create backup: server is already over storage limit (%.2fMB used of %.2fMB limit). Please delete old backups first.',
$usage['used_mb'],
$usage['limit_mb']
));
}
elseif ($server->hasBackupCountLimit() && $successful->count() >= $server->backup_limit) {
if (!$override) {
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);
@@ -107,27 +118,29 @@ class InitiateBackupService
}
return $this->connection->transaction(function () use ($server, $name) {
// Sanitize backup name to prevent injection
$backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString());
$backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName);
$backupName = substr($backupName, 0, 191); // Limit to database field length
$serverState = $this->serverStateService->captureServerState($server);
// Use the configured default adapter
$adapter = $this->backupManager->getDefaultAdapter();
/** @var Backup $backup */
$backup = $this->repository->create([
'server_id' => $server->id,
'uuid' => Uuid::uuid4()->toString(),
'name' => $backupName,
'ignored_files' => array_values($this->ignoredFiles ?? []),
'disk' => $this->backupManager->getDefaultAdapter(),
'disk' => $adapter,
'is_locked' => $this->isLocked,
'server_state' => $serverState,
], true, true);
try {
$this->daemonBackupRepository->setServer($server)
->setBackupAdapter($this->backupManager->getDefaultAdapter())
->setBackupAdapter($adapter)
->backup($backup);
} catch (\Exception $e) {
// If daemon backup request fails, clean up the backup record
@@ -160,4 +173,5 @@ class InitiateBackupService
throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.');
}
}
}

View File

@@ -76,9 +76,13 @@ class DatabaseManagementService
}
if ($this->validateDatabaseLimit) {
if (!$server->allowsDatabases()) {
throw new TooManyDatabasesException();
}
// If the server has a limit assigned and we've already reached that limit, throw back
// an exception and kill the process.
if (!is_null($server->database_limit) && $server->databases()->count() >= $server->database_limit) {
if ($server->hasDatabaseCountLimit() && $server->databases()->count() >= $server->database_limit) {
throw new TooManyDatabasesException();
}
}

View File

@@ -49,9 +49,10 @@ class BuildModificationService
$merge = Arr::only($data, ['oom_disabled', 'exclude_from_resource_calculation', 'memory', 'overhead_memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'allocation_id']);
$server->forceFill(array_merge($merge, [
'database_limit' => Arr::get($data, 'database_limit', 0) ?? null,
'allocation_limit' => Arr::get($data, 'allocation_limit', 0) ?? null,
'backup_limit' => Arr::get($data, 'backup_limit', 0) ?? 0,
'database_limit' => Arr::get($data, 'database_limit') === '' ? null : Arr::get($data, 'database_limit'),
'allocation_limit' => Arr::get($data, 'allocation_limit') === '' ? null : Arr::get($data, 'allocation_limit'),
'backup_limit' => Arr::get($data, 'backup_limit') === '' ? null : Arr::get($data, 'backup_limit'),
'backup_storage_limit' => Arr::get($data, 'backup_storage_limit') === '' ? null : Arr::get($data, 'backup_storage_limit'),
]))->saveOrFail();
return $server->refresh();

View File

@@ -161,9 +161,10 @@ class ServerCreationService
'egg_id' => Arr::get($data, 'egg_id'),
'startup' => Arr::get($data, 'startup'),
'image' => Arr::get($data, 'image'),
'database_limit' => Arr::get($data, 'database_limit') ?? 0,
'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0,
'backup_limit' => Arr::get($data, 'backup_limit') ?? 0,
'database_limit' => Arr::get($data, 'database_limit'),
'allocation_limit' => Arr::get($data, 'allocation_limit'),
'backup_limit' => Arr::get($data, 'backup_limit'),
'backup_storage_limit' => Arr::get($data, 'backup_storage_limit'),
]);
return $model;

View File

@@ -75,6 +75,7 @@ class ServerTransformer extends BaseTransformer
'databases' => $server->database_limit,
'allocations' => $server->allocation_limit,
'backups' => $server->backup_limit,
'backup_storage_mb' => $server->backup_storage_limit,
],
'user' => $server->owner_id,
'node' => $server->node_id,

View File

@@ -27,6 +27,10 @@ class BackupTransformer extends BaseClientTransformer
'ignored_files' => $backup->ignored_files,
'checksum' => $backup->checksum,
'bytes' => $backup->bytes,
'size_gb' => round($backup->bytes / (1024 * 1024 * 1024), 3),
'adapter' => $backup->disk,
'is_rustic' => $backup->isRustic(),
'snapshot_id' => $backup->snapshot_id,
'created_at' => $backup->created_at->toAtomString(),
'completed_at' => $backup->completed_at ? $backup->completed_at->toAtomString() : null,
];

View File

@@ -71,6 +71,7 @@ class ServerTransformer extends BaseClientTransformer
'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".

View File

@@ -6,7 +6,8 @@ return [
// The backup driver to use for this Panel instance. All client generated server backups
// will be stored in this location by default. It is possible to change this once backups
// have been made, without losing data.
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_WINGS),
// Options: wings, s3, rustic_local, rustic_s3
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_RUSTIC_LOCAL),
// 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.
@@ -52,5 +53,53 @@ return [
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
],
// Configuration for Rustic local backups. Rustic provides deduplicated,
// encrypted backups with fast incremental snapshots.
'rustic_local' => [
'adapter' => Backup::ADAPTER_RUSTIC_LOCAL,
// Path to rustic binary
'binary_path' => env('RUSTIC_BINARY_PATH', 'rustic'),
// Base path where rustic repositories will be stored (one per server)
'repository_base_path' => env('RUSTIC_REPOSITORY_BASE_PATH', '/var/lib/pterodactyl/rustic-repos'),
// Repository version (optional, default handled by rustic)
'repository_version' => env('RUSTIC_REPOSITORY_VERSION', 2),
// Pack size configuration for performance tuning
'tree_pack_size_mb' => env('RUSTIC_TREE_PACK_SIZE_MB', 4),
'data_pack_size_mb' => env('RUSTIC_DATA_PACK_SIZE_MB', 32),
// Hot/cold storage setup option
'use_cold_storage' => env('RUSTIC_LOCAL_USE_COLD_STORAGE', false),
'hot_repository_path' => env('RUSTIC_LOCAL_HOT_REPOSITORY_PATH', ''),
],
// Configuration for Rustic S3 backups. Combines Rustic's features with S3 storage.
'rustic_s3' => [
'adapter' => Backup::ADAPTER_RUSTIC_S3,
// S3 configuration
'endpoint' => env('RUSTIC_S3_ENDPOINT', env('AWS_ENDPOINT')),
'region' => env('RUSTIC_S3_REGION', env('AWS_DEFAULT_REGION', 'us-east-1')),
'bucket' => env('RUSTIC_S3_BUCKET'),
'prefix' => env('RUSTIC_S3_PREFIX', 'pterodactyl-backups/'),
// S3 credentials
'key' => env('RUSTIC_S3_ACCESS_KEY_ID', env('AWS_ACCESS_KEY_ID')),
'secret' => env('RUSTIC_S3_SECRET_ACCESS_KEY', env('AWS_SECRET_ACCESS_KEY')),
// Hot/cold storage configuration
'use_cold_storage' => env('RUSTIC_S3_USE_COLD_STORAGE', false),
'hot_bucket' => env('RUSTIC_S3_HOT_BUCKET', ''),
'cold_storage_class' => env('RUSTIC_S3_COLD_STORAGE_CLASS', 'GLACIER'),
// Connection options
'force_path_style' => env('RUSTIC_S3_FORCE_PATH_STYLE', false),
'disable_ssl' => env('RUSTIC_S3_DISABLE_SSL', false),
'ca_cert_path' => env('RUSTIC_S3_CA_CERT_PATH', ''),
],
],
];

View File

@@ -42,7 +42,8 @@ class ServerFactory extends Factory
'image' => 'foo/bar:latest',
'allocation_limit' => null,
'database_limit' => null,
'backup_limit' => 0,
'backup_limit' => null,
'backup_storage_limit' => null,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];

View File

@@ -0,0 +1,38 @@
<?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('backups', function (Blueprint $table) {
// Modify the disk column to support rustic adapters
$table->enum('disk', ['wings', 's3', 'rustic_local', 'rustic_s3'])->default('wings')->change();
// Add rustic-specific columns
$table->string('snapshot_id', 64)->nullable()->after('checksum')->comment('Rustic snapshot ID for rustic backups');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('backups', function (Blueprint $table) {
// Revert disk column to original enum values
$table->enum('disk', ['wings', 's3'])->default('wings')->change();
// Drop rustic-specific columns
$table->dropColumn('snapshot_id');
});
}
};

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('servers', function (Blueprint $table) {
$table->unsignedInteger('backup_storage_limit')->nullable()->after('backup_limit');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('backup_storage_limit');
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->unsignedInteger('database_limit')->nullable()->change();
$table->unsignedInteger('allocation_limit')->nullable()->change();
$table->unsignedInteger('backup_limit')->nullable()->change();
});
}
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->unsignedInteger('database_limit')->default(0)->change();
$table->unsignedInteger('allocation_limit')->default(0)->change();
$table->unsignedInteger('backup_limit')->default(0)->change();
});
}
};

View File

@@ -39,6 +39,7 @@ export interface Server {
databases: number;
allocations: number;
backups: number;
backupStorageMb: number | null;
};
isTransferring: boolean;
variables: ServerEggVariable[];

View File

@@ -14,6 +14,10 @@ export interface ServerBackup {
ignoredFiles: string;
checksum: string;
bytes: number;
sizeGb: number;
adapter: string;
isRustic: boolean;
snapshotId: string | null;
createdAt: Date;
completedAt: Date | null;
}

View File

@@ -15,7 +15,23 @@ interface ctx {
export const Context = createContext<ctx>({ page: 1, setPage: () => 1 });
type BackupResponse = PaginatedResult<ServerBackup> & { backupCount: number };
type BackupResponse = PaginatedResult<ServerBackup> & {
backupCount: number;
storage: {
used_mb: number;
limit_mb: number | null;
has_limit: boolean;
usage_percentage: number | null;
available_mb: number | null;
is_over_limit: boolean;
};
limits: {
count_limit: number | null;
has_count_limit: boolean;
storage_limit_mb: number | null;
has_storage_limit: boolean;
};
};
export default () => {
const { page } = useContext(Context);
@@ -28,6 +44,8 @@ export default () => {
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
backupCount: data.meta.backup_count,
storage: data.meta.storage,
limits: data.meta.limits,
};
});
};

View File

@@ -62,6 +62,10 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv
ignoredFiles: attributes.ignored_files,
checksum: attributes.checksum,
bytes: attributes.bytes,
sizeGb: attributes.size_gb,
adapter: attributes.adapter,
isRustic: attributes.is_rustic,
snapshotId: attributes.snapshot_id,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});

View File

@@ -104,9 +104,9 @@ interface ServerMobileMenuProps {
isVisible: boolean;
onClose: () => void;
serverId?: string;
databaseLimit?: number;
backupLimit?: number;
allocationLimit?: number;
databaseLimit?: number | null;
backupLimit?: number | null;
allocationLimit?: number | null;
subdomainSupported?: boolean;
}
@@ -114,9 +114,9 @@ export const ServerMobileMenu = ({
isVisible,
onClose,
serverId,
databaseLimit = 0,
backupLimit = 0,
allocationLimit = 0,
databaseLimit,
backupLimit,
allocationLimit,
subdomainSupported = false,
}: ServerMobileMenuProps) => {
const NavigationItem = ({
@@ -162,7 +162,7 @@ export const ServerMobileMenu = ({
</NavigationItem>
</Can>
{databaseLimit > 0 && (
{databaseLimit !== 0 && (
<Can action={'database.*'} matchAny>
<NavigationItem to={`/server/${serverId}/databases`} icon={HugeIconsDatabase} end>
Databases
@@ -170,7 +170,7 @@ export const ServerMobileMenu = ({
</Can>
)}
{backupLimit > 0 && (
{backupLimit !== 0 && (
<Can action={'backup.*'} matchAny>
<NavigationItem to={`/server/${serverId}/backups`} icon={HugeIconsCloudUp} end>
Backups

View File

@@ -24,6 +24,17 @@ import { ServerContext } from '@/state/server';
import useFlash from '@/plugins/useFlash';
// Helper function to format storage values
const formatStorage = (mb: number | undefined | null): string => {
if (mb === null || mb === undefined) {
return '0MB';
}
if (mb >= 1024) {
return `${(mb / 1024).toFixed(1)}GB`;
}
return `${mb.toFixed(1)}MB`;
};
interface BackupValues {
name: string;
ignored: string;
@@ -90,6 +101,8 @@ const BackupContainer = () => {
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb);
const hasBackupsInProgress = backups?.items.some((backup) => backup.completedAt === null) || false;
@@ -166,16 +179,57 @@ const BackupContainer = () => {
titleChildren={
<Can action={'backup.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{backupLimit > 0 && (
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{backups.backupCount} of {backupLimit} backups
</p>
)}
{backupLimit > 0 && backupLimit > backups.backupCount && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Backup
</ActionButton>
)}
<div className='flex flex-col gap-1 text-center sm:text-right'>
{/* Backup Count Display */}
{backupLimit === null && (
<p className='text-sm text-zinc-300'>
{backups.backupCount} backups
</p>
)}
{backupLimit > 0 && (
<p className='text-sm text-zinc-300'>
{backups.backupCount} of {backupLimit} backups
</p>
)}
{backupLimit === 0 && (
<p className='text-sm text-red-400'>
Backups disabled
</p>
)}
{/* Storage Usage Display */}
{backups.storage && (
<div className='flex flex-col gap-0.5'>
{backupStorageLimit === null ? (
<p
className='text-sm text-zinc-300 cursor-help'
title={`${backups.storage.used_mb?.toFixed(2) || 0}MB used(No Limit)`}
>
<span className='font-medium'>{formatStorage(backups.storage.used_mb)}</span> storage used
</p>
) : (
<>
<p
className='text-sm text-zinc-300 cursor-help'
title={`${backups.storage.used_mb?.toFixed(2) || 0}MB used of ${backupStorageLimit}MB (${backups.storage.available_mb?.toFixed(2) || 0}MB Available)`}
>
<span className='font-medium'>{formatStorage(backups.storage.used_mb)}</span> {' '}
{backupStorageLimit === null ?
"used" :
(<span className='font-medium'>of {formatStorage(backupStorageLimit)} used</span>)}
</p>
</>
)}
</div>
)}
</div>
{(backupLimit === null || backupLimit > backups.backupCount) &&
(!backupStorageLimit || !backups.storage?.is_over_limit) && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Backup
</ActionButton>
)}
</div>
</Can>
}
@@ -215,12 +269,12 @@ const BackupContainer = () => {
</svg>
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{backupLimit > 0 ? 'No backups found' : 'Backups unavailable'}
{backupLimit === 0 ? 'Backups unavailable' : 'No backups found'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{backupLimit > 0
? 'Your server does not have any backups. Create one to get started.'
: 'Backups cannot be created for this server.'}
{backupLimit === 0
? 'Backups cannot be created for this server.'
: 'Your server does not have any backups. Create one to get started.'}
</p>
</div>
</div>

View File

@@ -25,8 +25,6 @@ const BackupRow = ({ backup }: Props) => {
useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async () => {
try {
// When backup completes, refresh the backup list from API to get accurate completion time
// This ensures we get the exact completion timestamp from the database, not the websocket receive time
await mutate();
} catch (e) {
console.warn(e);
@@ -36,7 +34,6 @@ const BackupRow = ({ backup }: Props) => {
return (
<PageListItem>
<div className='flex items-center gap-4 w-full py-1'>
{/* Status Icon */}
<div className='flex-shrink-0 w-8 h-8 rounded-lg bg-[#ffffff11] flex items-center justify-center'>
{backup.completedAt === null ? (
<Spinner size={'small'} />
@@ -49,7 +46,6 @@ const BackupRow = ({ backup }: Props) => {
)}
</div>
{/* Main Content */}
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 mb-1'>
{backup.completedAt !== null && !backup.isSuccessful && (
@@ -69,7 +65,6 @@ const BackupRow = ({ backup }: Props) => {
{backup.checksum && <p className='text-xs text-zinc-400 font-mono truncate'>{backup.checksum}</p>}
</div>
{/* Size Info */}
{backup.completedAt !== null && backup.isSuccessful && (
<div className='hidden sm:block flex-shrink-0 text-right'>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Size</p>
@@ -77,7 +72,6 @@ const BackupRow = ({ backup }: Props) => {
</div>
)}
{/* Date Info */}
<div className='hidden sm:block flex-shrink-0 text-right min-w-[120px]'>
<p className='text-xs text-zinc-500 uppercase tracking-wide'>Created</p>
<p
@@ -88,7 +82,6 @@ const BackupRow = ({ backup }: Props) => {
</p>
</div>
{/* Actions Menu */}
<div className='flex-shrink-0'>
<Can action={['backup.download', 'backup.restore', 'backup.delete']} matchAny>
{backup.completedAt ? <BackupContextMenu backup={backup} /> : null}

View File

@@ -92,12 +92,22 @@ const DatabasesContainer = () => {
titleChildren={
<Can action={'database.create'}>
<div className='flex flex-col sm:flex-row items-center justify-end gap-4'>
{databaseLimit === null && (
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{databases.length} databases (unlimited)
</p>
)}
{databaseLimit > 0 && (
<p className='text-sm text-zinc-300 text-center sm:text-right'>
{databases.length} of {databaseLimit} databases
</p>
)}
{databaseLimit > 0 && databaseLimit !== databases.length && (
{databaseLimit === 0 && (
<p className='text-sm text-red-400 text-center sm:text-right'>
Databases disabled
</p>
)}
{(databaseLimit === null || (databaseLimit > 0 && databaseLimit !== databases.length)) && (
<ActionButton variant='primary' onClick={() => setCreateModalVisible(true)}>
New Database
</ActionButton>
@@ -177,12 +187,12 @@ const DatabasesContainer = () => {
<HugeIconsDatabase className='w-8 h-8 text-zinc-400' fill='currentColor' />
</div>
<h3 className='text-lg font-medium text-zinc-200 mb-2'>
{databaseLimit > 0 ? 'No databases found' : 'Databases unavailable'}
{databaseLimit === 0 ? 'Databases unavailable' : 'No databases found'}
</h3>
<p className='text-sm text-zinc-400 max-w-sm'>
{databaseLimit > 0
? 'Your server does not have any databases. Create one to get started.'
: 'Databases cannot be created for this server.'}
{databaseLimit === 0
? 'Databases cannot be created for this server.'
: 'Your server does not have any databases. Create one to get started.'}
</p>
</div>
</div>

View File

@@ -73,13 +73,25 @@ const NetworkContainer = () => {
<div className='bg-gradient-to-b from-[#ffffff08] to-[#ffffff05] border-[1px] border-[#ffffff12] rounded-xl p-6 shadow-sm mt-8'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-extrabold tracking-tight'>Port Allocations</h3>
{data && allocationLimit > 0 && (
{data && (
<Can action={'allocation.create'}>
<div className='flex items-center gap-4'>
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
{data.filter((allocation) => !allocation.isDefault).length} of {allocationLimit}
</span>
{allocationLimit > data.filter((allocation) => !allocation.isDefault).length && (
{allocationLimit === null && (
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
{data.filter((allocation) => !allocation.isDefault).length} allocations (unlimited)
</span>
)}
{allocationLimit > 0 && (
<span className='text-sm text-zinc-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
{data.filter((allocation) => !allocation.isDefault).length} of {allocationLimit}
</span>
)}
{allocationLimit === 0 && (
<span className='text-sm text-red-400 bg-[#ffffff08] px-3 py-1 rounded-lg border border-[#ffffff15]'>
Allocations disabled
</span>
)}
{(allocationLimit === null || (allocationLimit > 0 && allocationLimit > data.filter((allocation) => !allocation.isDefault).length)) && (
<ActionButton variant='primary' onClick={onCreateAllocation} size='sm'>
New Allocation
</ActionButton>
@@ -120,12 +132,12 @@ const NetworkContainer = () => {
</svg>
</div>
<h4 className='text-lg font-medium text-zinc-200 mb-2'>
{allocationLimit > 0 ? 'No allocations found' : 'Allocations unavailable'}
{allocationLimit === 0 ? 'Allocations unavailable' : 'No allocations found'}
</h4>
<p className='text-sm text-zinc-400 max-w-sm text-center'>
{allocationLimit > 0
? 'Create your first allocation to get started.'
: 'Network allocations cannot be created for this server.'}
{allocationLimit === 0
? 'Network allocations cannot be created for this server.'
: 'Create your first allocation to get started.'}
</p>
</div>
</div>

View File

@@ -51,9 +51,9 @@ const blank_egg_prefix = '@';
// Sidebar item components that check both permissions and feature limits
const DatabasesSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
({ id, onClick }, ref) => {
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases ?? 0);
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
// Hide if no database access (limit is 0)
// Hide if databases are disabled (limit is 0)
if (databaseLimit === 0) return null;
return (
@@ -76,9 +76,9 @@ DatabasesSidebarItem.displayName = 'DatabasesSidebarItem';
const BackupsSidebarItem = React.forwardRef<HTMLAnchorElement, { id: string; onClick: () => void }>(
({ id, onClick }, ref) => {
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups ?? 0);
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
// Hide if no backup access (limit is 0)
// Hide if backups are disabled (limit is 0)
if (backupLimit === 0) return null;
return (
@@ -165,9 +165,9 @@ const ServerRouter = () => {
const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions((actions) => actions.clearServerState);
const egg_id = ServerContext.useStoreState((state) => state.server.data?.egg);
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases ?? 0);
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups ?? 0);
const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations ?? 0);
const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases);
const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups);
const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations);
// Mobile menu state
const [isMobileMenuVisible, setMobileMenuVisible] = useState(false);

View File

@@ -112,23 +112,31 @@
<div class="form-group col-xs-6">
<label for="pDatabaseLimit" class="control-label">Database Limit</label>
<div>
<input type="text" id="pDatabaseLimit" name="database_limit" class="form-control" value="{{ old('database_limit', 0) }}"/>
<input type="text" id="pDatabaseLimit" name="database_limit" class="form-control" value="{{ old('database_limit') }}" placeholder="Leave blank for unlimited"/>
</div>
<p class="text-muted small">The total number of databases a user is allowed to create for this server.</p>
<p class="text-muted small">The total number of databases a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
</div>
<div class="form-group col-xs-6">
<label for="pAllocationLimit" class="control-label">Allocation Limit</label>
<div>
<input type="text" id="pAllocationLimit" name="allocation_limit" class="form-control" value="{{ old('allocation_limit', 0) }}"/>
<input type="text" id="pAllocationLimit" name="allocation_limit" class="form-control" value="{{ old('allocation_limit') }}" placeholder="Leave blank for unlimited"/>
</div>
<p class="text-muted small">The total number of allocations a user is allowed to create for this server.</p>
<p class="text-muted small">The total number of allocations a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
</div>
<div class="form-group col-xs-6">
<label for="pBackupLimit" class="control-label">Backup Limit</label>
<div>
<input type="text" id="pBackupLimit" name="backup_limit" class="form-control" value="{{ old('backup_limit', 0) }}"/>
<input type="text" id="pBackupLimit" name="backup_limit" class="form-control" value="{{ old('backup_limit') }}" placeholder="Leave blank for unlimited"/>
</div>
<p class="text-muted small">The total number of backups that can be created for this server.</p>
<p class="text-muted small">The total number of backups that can be created for this server. Leave blank for unlimited, set to 0 to disable.</p>
</div>
<div class="form-group col-xs-6">
<label for="pBackupStorageLimit" class="control-label">Backup Storage Limit</label>
<div class="input-group">
<input type="text" id="pBackupStorageLimit" name="backup_storage_limit" data-multiplicator="true" class="form-control" value="{{ old('backup_storage_limit') }}" placeholder="Leave blank for unlimited"/>
<span class="input-group-addon">MiB</span>
</div>
<p class="text-muted small">The total storage space that can be used for backups. Leave blank for unlimited storage.</p>
</div>
</div>
</div>

View File

@@ -127,21 +127,29 @@
<div>
<input type="text" name="database_limit" class="form-control" value="{{ old('database_limit', $server->database_limit) }}"/>
</div>
<p class="text-muted small">The total number of databases a user is allowed to create for this server.</p>
<p class="text-muted small">The total number of databases a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
</div>
<div class="form-group col-xs-6">
<label for="allocation_limit" class="control-label">Allocation Limit</label>
<div>
<input type="text" name="allocation_limit" class="form-control" value="{{ old('allocation_limit', $server->allocation_limit) }}"/>
</div>
<p class="text-muted small">The total number of allocations a user is allowed to create for this server.</p>
<p class="text-muted small">The total number of allocations a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.</p>
</div>
<div class="form-group col-xs-6">
<label for="backup_limit" class="control-label">Backup Limit</label>
<div>
<input type="text" name="backup_limit" class="form-control" value="{{ old('backup_limit', $server->backup_limit) }}"/>
</div>
<p class="text-muted small">The total number of backups that can be created for this server.</p>
<p class="text-muted small">The total number of backups that can be created for this server. Leave blank for unlimited, set to 0 to disable.</p>
</div>
<div class="form-group col-xs-6">
<label for="backup_storage_limit" class="control-label">Backup Storage Limit</label>
<div class="input-group">
<input type="text" name="backup_storage_limit" data-multiplicator="true" class="form-control" value="{{ old('backup_storage_limit', $server->backup_storage_limit) }}"/>
<span class="input-group-addon">MiB</span>
</div>
<p class="text-muted small">The total storage space that can be used for backups. Leave blank for unlimited storage.</p>
</div>
</div>
</div>

View File

@@ -15,6 +15,8 @@ Route::group(['prefix' => '/servers/{uuid}'], function () {
Route::get('/install', [Remote\Servers\ServerInstallController::class, 'index']);
Route::post('/install', [Remote\Servers\ServerInstallController::class, 'store']);
Route::get('/rustic-config', [Remote\RusticConfigController::class, 'show']);
Route::get('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);
Route::get('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']);
Route::post('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);