Merge pull request #2434 from pressstartearly/develop

Added Autoallocation Button
This commit is contained in:
Dane Everitt 2020-10-31 22:30:16 -07:00 committed by GitHub
commit fda50bb6e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 842 additions and 140 deletions

View File

@ -0,0 +1,18 @@
<?php
namespace Pterodactyl\Exceptions\Service\Allocation;
use Pterodactyl\Exceptions\DisplayException;
class AutoAllocationNotEnabledException extends DisplayException
{
/**
* AutoAllocationNotEnabledException constructor.
*/
public function __construct()
{
parent::__construct(
'Server auto-allocation is not enabled for this instance.'
);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Pterodactyl\Exceptions\Service\Allocation;
use Pterodactyl\Exceptions\DisplayException;
class NoAutoAllocationSpaceAvailableException extends DisplayException
{
/**
* NoAutoAllocationSpaceAvailableException constructor.
*/
public function __construct()
{
parent::__construct(
'Cannot assign additional allocation: no more space available on node.'
);
}
}

View File

@ -10,7 +10,9 @@ use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Eloquent\AllocationRepository;
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;
@ -27,20 +29,28 @@ class NetworkAllocationController extends ClientApiController
*/
private $serverRepository;
/**
* @var \Pterodactyl\Services\Allocations\FindAssignableAllocationService
*/
private $assignableAllocationService;
/**
* NetworkController constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\AllocationRepository $repository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $serverRepository
* @param \Pterodactyl\Services\Allocations\FindAssignableAllocationService $assignableAllocationService
*/
public function __construct(
AllocationRepository $repository,
ServerRepository $serverRepository
ServerRepository $serverRepository,
FindAssignableAllocationService $assignableAllocationService
) {
parent::__construct();
$this->repository = $repository;
$this->serverRepository = $serverRepository;
$this->assignableAllocationService = $assignableAllocationService;
}
/**
@ -100,6 +110,31 @@ class NetworkAllocationController extends ClientApiController
->toArray();
}
/**
* Set the notes for the allocation for a server.
*s
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\NewAllocationRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Pterodactyl\Exceptions\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);
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
/**
* Delete an allocation from a server.
*
@ -109,18 +144,19 @@ class NetworkAllocationController extends ClientApiController
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function delete(DeleteAllocationRequest $request, Server $server, Allocation $allocation)
{
if ($allocation->id === $server->allocation_id) {
throw new DisplayException(
'Cannot delete the primary allocation for a server.'
'You cannot delete the primary allocation for this server.'
);
}
$this->repository->update($allocation->id, ['server_id' => null, 'notes' => null]);
Allocation::query()->where('id', $allocation->id)->update([
'notes' => null,
'server_id' => null,
]);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}

View File

@ -19,6 +19,11 @@ class AdvancedSettingsFormRequest extends AdminFormRequest
'recaptcha:website_key' => 'required|string|max:191',
'pterodactyl:guzzle:timeout' => 'required|integer|between:1,60',
'pterodactyl:guzzle:connect_timeout' => 'required|integer|between:1,60',
'pterodactyl:console:count' => 'required|integer|min:1',
'pterodactyl:console:frequency' => 'required|integer|min:10',
'pterodactyl:client_features:allocations:enabled' => 'required|in:true,false',
'pterodactyl:client_features:allocations:range_start' => 'required|integer|between:1024,65535',
'pterodactyl:client_features:allocations:range_end' => 'required|integer|between:1024,65535',
];
}
@ -33,6 +38,11 @@ class AdvancedSettingsFormRequest extends AdminFormRequest
'recaptcha:website_key' => 'reCAPTCHA Website Key',
'pterodactyl:guzzle:timeout' => 'HTTP Request Timeout',
'pterodactyl:guzzle:connect_timeout' => 'HTTP Connection Timeout',
'pterodactyl:console:count' => 'Console Message Count',
'pterodactyl:console:frequency' => 'Console Frequency Tick',
'pterodactyl:client_features:allocations:enabled' => 'Auto Create Allocations Enabled',
'pterodactyl:client_features:allocations:range_start' => 'Starting Port',
'pterodactyl:client_features:allocations:range_end' => 'Ending Port',
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Network;
use Illuminate\Support\Collection;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class NewAllocationRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission(): string
{
return Permission::ACTION_ALLOCATION_CREATE;
}
}

View File

@ -30,6 +30,9 @@ class SettingsServiceProvider extends ServiceProvider
'pterodactyl:console:count',
'pterodactyl:console:frequency',
'pterodactyl:auth:2fa_required',
'pterodactyl:client_features:allocations:enabled',
'pterodactyl:client_features:allocations:range_start',
'pterodactyl:client_features:allocations:range_end',
];
/**

View File

@ -0,0 +1,125 @@
<?php
namespace Pterodactyl\Services\Allocations;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Exceptions\Service\Allocation\AutoAllocationNotEnabledException;
use Pterodactyl\Exceptions\Service\Allocation\NoAutoAllocationSpaceAvailableException;
class FindAssignableAllocationService
{
/**
* @var \Pterodactyl\Services\Allocations\AssignmentService
*/
private $service;
/**
* FindAssignableAllocationService constructor.
*
* @param \Pterodactyl\Services\Allocations\AssignmentService $service
*/
public function __construct(AssignmentService $service)
{
$this->service = $service;
}
/**
* Finds an existing unassigned allocation and attempts to assign it to the given server. If
* no allocation can be found, a new one will be created with a random port between the defined
* range from the configuration.
*
* @param \Pterodactyl\Models\Server $server
* @return \Pterodactyl\Models\Allocation
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Service\Allocation\CidrOutOfRangeException
* @throws \Pterodactyl\Exceptions\Service\Allocation\InvalidPortMappingException
* @throws \Pterodactyl\Exceptions\Service\Allocation\PortOutOfRangeException
* @throws \Pterodactyl\Exceptions\Service\Allocation\TooManyPortsInRangeException
*/
public function handle(Server $server)
{
if (! config('pterodactyl.client_features.allocations.enabled')) {
throw new AutoAllocationNotEnabledException;
}
// Attempt to find a given available allocation for a server. If one cannot be found
// we will fall back to attempting to create a new allocation that can be used for the
// server.
/** @var \Pterodactyl\Models\Allocation|null $allocation */
$allocation = $server->node->allocations()
->where('ip', $server->allocation->ip)
->whereNull('server_id')
->inRandomOrder()
->first();
$allocation = $allocation ?? $this->createNewAllocation($server);
$allocation->update(['server_id' => $server->id]);
return $allocation->refresh();
}
/**
* Create a new allocation on the server's node with a random port from the defined range
* in the settings. If there are no matches in that range, or something is wrong with the
* range information provided an exception will be raised.
*
* @param \Pterodactyl\Models\Server $server
* @return \Pterodactyl\Models\Allocation
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Service\Allocation\CidrOutOfRangeException
* @throws \Pterodactyl\Exceptions\Service\Allocation\InvalidPortMappingException
* @throws \Pterodactyl\Exceptions\Service\Allocation\PortOutOfRangeException
* @throws \Pterodactyl\Exceptions\Service\Allocation\TooManyPortsInRangeException
*/
protected function createNewAllocation(Server $server): Allocation
{
$start = config('pterodactyl.client_features.allocations.range_start', null);
$end = config('pterodactyl.client_features.allocations.range_end', null);
if (! $start || ! $end) {
throw new NoAutoAllocationSpaceAvailableException;
}
Assert::integerish($start);
Assert::integerish($end);
// Get all of the currently allocated ports for the node so that we can figure out
// which port might be available.
$ports = $server->node->allocations()
->where('ip', $server->allocation->ip)
->whereBetween('port', [$start, $end])
->pluck('port');
// Compute the difference of the range and the currently created ports, finding
// any port that does not already exist in the database. We will then use this
// array of ports to create a new allocation to assign to the server.
$available = array_diff(range($start, $end), $ports->toArray());
// If we've already allocated all of the ports, just abort.
if (empty($available)) {
throw new NoAutoAllocationSpaceAvailableException;
}
// Pick a random port out of the remaining available ports.
/** @var int $port */
$port = $available[array_rand($available)];
$this->service->handle($server->node, [
'allocation_ip' => $server->allocation->ip,
'allocation_ports' => [$port],
]);
/** @var \Pterodactyl\Models\Allocation $allocation */
$allocation = $server->node->allocations()
->where('ip', $server->allocation->ip)
->where('port', $port)
->firstOrFail();
return $allocation;
}
}

View File

@ -9,7 +9,7 @@ return [
| change this value if you are not maintaining your own internal versions.
*/
'version' => 'canary',
'version' => '1.0.1',
/*
|--------------------------------------------------------------------------

View File

@ -143,6 +143,12 @@ return [
// The total number of tasks that can exist for any given schedule at once.
'per_schedule_task_limit' => 10,
],
'allocations' => [
'enabled' => env('PTERODACTYL_CLIENT_ALLOCATIONS_ENABLED', false),
'range_start' => env('PTERODACTYL_CLIENT_ALLOCATIONS_RANGE_START'),
'range_end' => env('PTERODACTYL_CLIENT_ALLOCATIONS_RANGE_END'),
],
],
/*

View File

@ -0,0 +1,9 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string): Promise<Allocation> => {
const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations`);
return rawDataToServerAllocation(data);
};

View File

@ -1,9 +0,0 @@
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
import { Allocation } from '@/api/server/getServer';
export default async (uuid: string): Promise<Allocation[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`);
return (data.data || []).map(rawDataToServerAllocation);
};

View File

@ -0,0 +1,15 @@
import { ServerContext } from '@/state/server';
import useSWR from 'swr';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
import { Allocation } from '@/api/server/getServer';
export default () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
return useSWR<Allocation[]>([ 'server:allocations', uuid ], async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`);
return (data.data || []).map(rawDataToServerAllocation);
}, { revalidateOnFocus: false, revalidateOnMount: false });
};

View File

@ -20,7 +20,7 @@ const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props)
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex flex-wrap items-center justify-end mt-8`}>
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto`}>
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}>
Cancel
</Button>
<Button color={'red'} css={tw`w-full sm:w-auto mt-4 sm:mt-0 sm:ml-4`} onClick={() => onConfirmed()}>

View File

@ -1,4 +1,4 @@
import React, { memo, useState } from 'react';
import React, { memo, useCallback, useState } from 'react';
import isEqual from 'react-fast-compare';
import tw from 'twin.macro';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -15,20 +15,26 @@ import setServerAllocationNotes from '@/api/server/network/setServerAllocationNo
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import CopyOnClick from '@/components/elements/CopyOnClick';
import DeleteAllocationButton from '@/components/server/network/DeleteAllocationButton';
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
import getServerAllocations from '@/api/swr/getServerAllocations';
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm inline-block`}`;
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
interface Props {
allocation: Allocation;
onSetPrimary: (id: number) => void;
onNotesChanged: (id: number, notes: string) => void;
}
const AllocationRow = ({ allocation, onSetPrimary, onNotesChanged }: Props) => {
const AllocationRow = ({ allocation }: Props) => {
const [ loading, setLoading ] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { mutate } = getServerAllocations();
const onNotesChanged = useCallback((id: number, notes: string) => {
mutate(data => data?.map(a => a.id === id ? { ...a, notes } : a), false);
}, []);
const setAllocationNotes = debounce((notes: string) => {
setLoading(true);
@ -40,14 +46,26 @@ const AllocationRow = ({ allocation, onSetPrimary, onNotesChanged }: Props) => {
.then(() => setLoading(false));
}, 750);
const setPrimaryAllocation = () => {
clearFlashes('server:network');
mutate(data => data?.map(a => ({ ...a, isDefault: a.id === allocation.id })), false);
setPrimaryServerAllocation(uuid, allocation.id)
.catch(error => {
clearAndAddHttpError({ key: 'server:network', error });
mutate();
});
};
return (
<GreyRowBox $hoverable={false} css={tw`flex-wrap md:flex-no-wrap mt-2`}>
<div css={tw`flex items-center w-full md:w-auto`}>
<div css={tw`pl-4 pr-6 text-neutral-400`}>
<FontAwesomeIcon icon={faNetworkWired} />
<FontAwesomeIcon icon={faNetworkWired}/>
</div>
<div css={tw`mr-4 flex-1 md:w-40`}>
{allocation.alias ? <CopyOnClick text={allocation.alias}><Code css={tw`w-40 truncate`}>{allocation.alias}</Code></CopyOnClick> :
{allocation.alias ?
<CopyOnClick text={allocation.alias}><Code css={tw`w-40 truncate`}>{allocation.alias}</Code></CopyOnClick> :
<CopyOnClick text={allocation.ip}><Code>{allocation.ip}</Code></CopyOnClick>}
<Label>{allocation.alias ? 'Hostname' : 'IP Address'}</Label>
</div>
@ -66,20 +84,25 @@ const AllocationRow = ({ allocation, onSetPrimary, onNotesChanged }: Props) => {
/>
</InputSpinner>
</div>
<div css={tw`w-full md:flex-none md:w-32 md:text-center mt-4 md:mt-0 text-right ml-4`}>
<div css={tw`w-full md:flex-none md:w-40 md:text-center mt-4 md:mt-0 ml-4 flex items-center justify-end`}>
{allocation.isDefault ?
<span css={tw`bg-green-500 py-1 px-2 rounded text-green-50 text-xs`}>Primary</span>
:
<Can action={'allocations.update'}>
<Button
isSecondary
size={'xsmall'}
color={'primary'}
onClick={() => onSetPrimary(allocation.id)}
>
Make Primary
</Button>
</Can>
<>
<Can action={'allocations.delete'}>
<DeleteAllocationButton allocation={allocation.id}/>
</Can>
<Can action={'allocations.update'}>
<Button
isSecondary
size={'xsmall'}
color={'primary'}
onClick={setPrimaryAllocation}
>
Make Primary
</Button>
</Can>
</>
}
</div>
</GreyRowBox>

View File

@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
import Icon from '@/components/elements/Icon';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import { ServerContext } from '@/state/server';
import deleteServerAllocation from '@/api/server/network/deleteServerAllocation';
import getServerAllocations from '@/api/swr/getServerAllocations';
import useFlash from '@/plugins/useFlash';
interface Props {
allocation: number;
}
const DeleteAllocationButton = ({ allocation }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const [ confirm, setConfirm ] = useState(false);
const { mutate } = getServerAllocations();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const deleteAllocation = () => {
clearFlashes('server:network');
mutate(data => data?.filter(a => a.id !== allocation), false);
deleteServerAllocation(uuid, allocation)
.catch(error => clearAndAddHttpError({ key: 'server:network', error }));
};
return (
<>
<ConfirmationModal
visible={confirm}
title={'Remove this allocation?'}
buttonText={'Delete'}
onConfirmed={deleteAllocation}
onModalDismissed={() => setConfirm(false)}
>
This allocation will be immediately removed from your server. Are you sure you want to continue?
</ConfirmationModal>
<button
css={tw`text-neutral-400 px-2 py-1 mr-2 transition-colors duration-150 hover:text-red-400`}
type={'button'}
onClick={() => setConfirm(true)}
>
<Icon icon={faTrashAlt} css={tw`w-3 h-auto`}/>
</button>
</>
);
};
export default DeleteAllocationButton;

View File

@ -1,24 +1,31 @@
import React, { useCallback, useEffect } from 'react';
import useSWR from 'swr';
import getServerAllocations from '@/api/server/network/getServerAllocations';
import { Allocation } from '@/api/server/getServer';
import React, { useEffect, useState } from 'react';
import Spinner from '@/components/elements/Spinner';
import useFlash from '@/plugins/useFlash';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import { ServerContext } from '@/state/server';
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
import AllocationRow from '@/components/server/network/AllocationRow';
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
import Button from '@/components/elements/Button';
import createServerAllocation from '@/api/server/network/createServerAllocation';
import tw from 'twin.macro';
import Can from '@/components/elements/Can';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerAllocations from '@/api/swr/getServerAllocations';
import isEqual from 'react-fast-compare';
import { Allocation } from '@/api/server/getServer';
const NetworkContainer = () => {
const [ loading, setLoading ] = useState(false);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const allocations = useDeepMemoize(ServerContext.useStoreState(state => state.server.data!.allocations));
const allocationLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.allocations);
// @ts-ignore
const allocations: Allocation[] = ServerContext.useStoreState(state => state.server.data!.allocations, isEqual);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), {
initialData: allocations,
revalidateOnFocus: false,
});
const { data, error, mutate } = getServerAllocations();
useEffect(() => {
mutate(allocations, false);
}, []);
useEffect(() => {
if (error) {
@ -26,36 +33,45 @@ const NetworkContainer = () => {
}
}, [ error ]);
const setPrimaryAllocation = useCallback((id: number) => {
const onCreateAllocation = () => {
clearFlashes('server:network');
const initial = data;
mutate(data?.map(a => a.id === id ? { ...a, isDefault: true } : { ...a, isDefault: false }), false);
setPrimaryServerAllocation(uuid, id)
.catch(error => {
clearAndAddHttpError({ key: 'server:network', error });
mutate(initial, false);
});
}, []);
const onNotesAdded = useCallback((id: number, notes: string) => {
mutate(data?.map(a => a.id === id ? { ...a, notes } : a), false);
}, []);
setLoading(true);
createServerAllocation(uuid)
.then(allocation => mutate(data?.concat(allocation), false))
.catch(error => clearAndAddHttpError({ key: 'server:network', error }))
.then(() => setLoading(false));
};
return (
<ServerContentBlock showFlashKey={'server:network'} title={'Network'}>
{!data ?
<Spinner size={'large'} centered/>
:
data.map(allocation => (
<AllocationRow
key={`${allocation.ip}:${allocation.port}`}
allocation={allocation}
onSetPrimary={setPrimaryAllocation}
onNotesChanged={onNotesAdded}
/>
))
<>
{
data.map(allocation => (
<AllocationRow
key={`${allocation.ip}:${allocation.port}`}
allocation={allocation}
/>
))
}
<Can action={'allocation.create'}>
<SpinnerOverlay visible={loading}/>
<div css={tw`mt-6 sm:flex items-center justify-end`}>
<p css={tw`text-sm text-neutral-300 mb-4 sm:mr-6 sm:mb-0`}>
You are currently using {data.length} of {allocationLimit} allowed allocations for this
server.
</p>
{allocationLimit > data.length &&
<Button css={tw`w-full sm:w-auto`} color={'primary'} onClick={onCreateAllocation}>
Create Allocation
</Button>
}
</div>
</Can>
</>
}
</ServerContentBlock>
);

View File

@ -115,7 +115,7 @@
<div>
<input type="text" name="allocation_limit" class="form-control" value="{{ old('allocation_limit', $server->allocation_limit) }}"/>
</div>
<p class="text-muted small"><strong>This feature is not currently implemented.</strong> 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.</p>
</div>
<div class="form-group col-xs-6">
<label for="backup_limit" class="control-label">Backup Limit</label>

View File

@ -82,6 +82,62 @@
</div>
</div>
</div>
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Console</h3>
</div>
<div class="box-body">
<div class="row">
<div class="form-group col-md-6">
<label class="control-label">Message Count</label>
<div>
<input type="number" required class="form-control" name="pterodactyl:console:count" value="{{ old('pterodactyl:console:count', config('pterodactyl.console.count')) }}">
<p class="text-muted small">The number of messages to be pushed to the console per frequency tick.</p>
</div>
</div>
<div class="form-group col-md-6">
<label class="control-label">Frequency Tick</label>
<div>
<input type="number" required class="form-control" name="pterodactyl:console:frequency" value="{{ old('pterodactyl:console:frequency', config('pterodactyl.console.frequency')) }}">
<p class="text-muted small">The amount of time in milliseconds between each console message sending tick.</p>
</div>
</div>
</div>
</div>
</div>
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Automatic Allocation Creation</h3>
</div>
<div class="box-body">
<div class="row">
<div class="form-group col-md-4">
<label class="control-label">Status</label>
<div>
<select class="form-control" name="pterodactyl:client_features:allocations:enabled">
<option value="false">Disabled</option>
<option value="true" @if(old('pterodactyl:client_features:allocations:enabled', config('pterodactyl.client_features.allocations.enabled'))) selected @endif>Enabled</option>
</select>
<p class="text-muted small">If enabled users will have the option to automatically create new allocations for their server via the frontend.</p>
</div>
</div>
<div class="form-group col-md-4">
<label class="control-label">Starting Port</label>
<div>
<input type="number" required class="form-control" name="pterodactyl:client_features:allocations:range_start" value="{{ old('pterodactyl:client_features:allocations:range_start', config('pterodactyl.client_features.allocations.range_start')) }}">
<p class="text-muted small">The starting port in the range that can be automatically allocated.</p>
</div>
</div>
<div class="form-group col-md-4">
<label class="control-label">Ending Port</label>
<div>
<input type="number" required class="form-control" name="pterodactyl:client_features:allocations:range_end" value="{{ old('pterodactyl:client_features:allocations:range_end', config('pterodactyl.client_features.allocations.range_end')) }}">
<p class="text-muted small">The ending port in the range that can be automatically allocated.</p>
</div>
</div>
</div>
</div>
</div>
<div class="box box-primary">
<div class="box-footer">
{{ csrf_field() }}

View File

@ -82,6 +82,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::group(['prefix' => '/network', 'middleware' => [AllocationBelongsToServer::class]], function () {
Route::get('/allocations', 'Servers\NetworkAllocationController@index');
Route::post('/allocations', 'Servers\NetworkAllocationController@store');
Route::post('/allocations/{allocation}', 'Servers\NetworkAllocationController@update');
Route::post('/allocations/{allocation}/primary', 'Servers\NetworkAllocationController@setPrimary');
Route::delete('/allocations/{allocation}', 'Servers\NetworkAllocationController@delete');

View File

@ -0,0 +1,97 @@
<?php
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Allocation;
use Illuminate\Http\Response;
use Pterodactyl\Models\Permission;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
class CreateNewAllocationTest extends ClientApiIntegrationTestCase
{
/**
* Setup tests.
*/
public function setUp(): void
{
parent::setUp();
config()->set('pterodactyl.client_features.allocations.enabled', true);
config()->set('pterodactyl.client_features.allocations.range_start', 5000);
config()->set('pterodactyl.client_features.allocations.range_end', 5050);
}
/**
* Tests that a new allocation can be properly assigned to a server.
*
* @param array $permission
* @dataProvider permissionDataProvider
*/
public function testNewAllocationCanBeAssignedToServer(array $permission)
{
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount($permission);
$server->update(['allocation_limit' => 2]);
$response = $this->actingAs($user)->postJson($this->link($server, "/network/allocations"));
$response->assertJsonPath('object', Allocation::RESOURCE_NAME);
$matched = Allocation::query()->findOrFail($response->json('attributes.id'));
$this->assertSame($server->id, $matched->server_id);
$this->assertJsonTransformedWith($response->json('attributes'), $matched);
}
/**
* Test that a user without the required permissions cannot create an allocation for
* the server instance.
*/
public function testAllocationCannotBeCreatedIfUserDoesNotHavePermission()
{
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount([Permission::ACTION_ALLOCATION_UPDATE]);
$server->update(['allocation_limit' => 2]);
$this->actingAs($user)->postJson($this->link($server, "/network/allocations"))->assertForbidden();
}
/**
* Test that an error is returned to the user if this feature is not enabled on the system.
*/
public function testAllocationCannotBeCreatedIfNotEnabled()
{
config()->set('pterodactyl.client_features.allocations.enabled', false);
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount();
$server->update(['allocation_limit' => 2]);
$this->actingAs($user)->postJson($this->link($server, "/network/allocations"))
->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonPath('errors.0.code', 'AutoAllocationNotEnabledException')
->assertJsonPath('errors.0.detail', 'Server auto-allocation is not enabled for this instance.');
}
/**
* Test that an allocation cannot be created if the server has reached it's allocation limit.
*/
public function testAllocationCannotBeCreatedIfServerIsAtLimit()
{
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount();
$server->update(['allocation_limit' => 1]);
$this->actingAs($user)->postJson($this->link($server, "/network/allocations"))
->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonPath('errors.0.code', 'DisplayException')
->assertJsonPath('errors.0.detail', 'Cannot assign additional allocations to this server: limit has been reached.');
}
/**
* @return array
*/
public function permissionDataProvider()
{
return [[[Permission::ACTION_ALLOCATION_CREATE]], [[]]];
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Allocation;
use Illuminate\Http\Response;
use Pterodactyl\Models\Permission;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
class DeleteAllocationTest extends ClientApiIntegrationTestCase
{
/**
* Test that an allocation is deleted from the server and the notes are properly reset
* to an empty value on assignment.
*
* @param array $permission
* @dataProvider permissionDataProvider
*/
public function testAllocationCanBeDeletedFromServer(array $permission)
{
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount($permission);
/** @var \Pterodactyl\Models\Allocation $allocation */
$allocation = factory(Allocation::class)->create([
'server_id' => $server->id,
'node_id' => $server->node_id,
'notes' => 'hodor',
]);
$this->actingAs($user)->deleteJson($this->link($allocation))->assertStatus(Response::HTTP_NO_CONTENT);
$this->assertDatabaseHas('allocations', ['id' => $allocation->id, 'server_id' => null, 'notes' => null]);
}
/**
* Test that an error is returned if the user does not have permissiont to delete an allocation.
*/
public function testErrorIsReturnedIfUserDoesNotHavePermission()
{
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount([Permission::ACTION_ALLOCATION_CREATE]);
/** @var \Pterodactyl\Models\Allocation $allocation */
$allocation = factory(Allocation::class)->create([
'server_id' => $server->id,
'node_id' => $server->node_id,
'notes' => 'hodor',
]);
$this->actingAs($user)->deleteJson($this->link($allocation))->assertForbidden();
$this->assertDatabaseHas('allocations', ['id' => $allocation->id, 'server_id' => $server->id]);
}
/**
* Test that an allocation is not deleted if it is currently marked as the primary allocation
* for the server.
*/
public function testErrorIsReturnedIfAllocationIsPrimary()
{
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount();
$this->actingAs($user)->deleteJson($this->link($server->allocation))
->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonPath('errors.0.code', 'DisplayException')
->assertJsonPath('errors.0.detail', 'You cannot delete the primary allocation for this server.');
}
/**
* Test that an allocation cannot be deleted if it does not belong to the server instance.
*/
public function testErrorIsReturnedIfAllocationDoesNotBelongToServer()
{
/** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount();
[, $server2] = $this->generateTestAccount();
$this->actingAs($user)->deleteJson($this->link($server2->allocation))->assertNotFound();
$this->actingAs($user)->deleteJson($this->link($server, "/network/allocations/{$server2->allocation_id}"))->assertNotFound();
}
/**
* @return array
*/
public function permissionDataProvider()
{
return [[[Permission::ACTION_ALLOCATION_DELETE]], [[]]];
}
}

View File

@ -4,7 +4,6 @@ namespace Pterodactyl\Tests\Integration\Api\Client\Server;
use Pterodactyl\Models\User;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Models\Permission;
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
@ -17,7 +16,6 @@ class NetworkAllocationControllerTest extends ClientApiIntegrationTestCase
public function testServerAllocationsAreReturned()
{
[$user, $server] = $this->generateTestAccount();
$allocation = $this->getAllocation($server);
$response = $this->actingAs($user)->getJson($this->link($server, '/network/allocations'));
@ -25,7 +23,7 @@ class NetworkAllocationControllerTest extends ClientApiIntegrationTestCase
$response->assertJsonPath('object', 'list');
$response->assertJsonCount(1, 'data');
$this->assertJsonTransformedWith($response->json('data.0.attributes'), $allocation);
$this->assertJsonTransformedWith($response->json('data.0.attributes'), $server->allocation);
}
/**
@ -57,7 +55,7 @@ class NetworkAllocationControllerTest extends ClientApiIntegrationTestCase
public function testAllocationNotesCanBeUpdated(array $permissions)
{
[$user, $server] = $this->generateTestAccount($permissions);
$allocation = $this->getAllocation($server);
$allocation = $server->allocation;
$this->assertNull($allocation->notes);
@ -92,13 +90,11 @@ class NetworkAllocationControllerTest extends ClientApiIntegrationTestCase
$server->owner_id = $user2->id;
$server->save();
$this->actingAs($user)->postJson($this->link($this->getAllocation($server)))
->assertNotFound();
$this->actingAs($user)->postJson($this->link($server->allocation))->assertNotFound();
[$user, $server] = $this->generateTestAccount([Permission::ACTION_ALLOCATION_CREATE]);
$this->actingAs($user)->postJson($this->link($this->getAllocation($server)))
->assertForbidden();
$this->actingAs($user)->postJson($this->link($server->allocation))->assertForbidden();
}
/**
@ -108,8 +104,8 @@ class NetworkAllocationControllerTest extends ClientApiIntegrationTestCase
public function testPrimaryAllocationCanBeModified(array $permissions)
{
[$user, $server] = $this->generateTestAccount($permissions);
$allocation = $this->getAllocation($server);
$allocation2 = $this->getAllocation($server);
$allocation = $server->allocation;
$allocation2 = factory(Allocation::class)->create(['node_id' => $server->node_id, 'server_id' => $server->id]);
$server->allocation_id = $allocation->id;
$server->save();
@ -130,61 +126,12 @@ class NetworkAllocationControllerTest extends ClientApiIntegrationTestCase
$server->owner_id = $user2->id;
$server->save();
$this->actingAs($user)->postJson($this->link($this->getAllocation($server), '/primary'))
$this->actingAs($user)->postJson($this->link($server->allocation, '/primary'))
->assertNotFound();
[$user, $server] = $this->generateTestAccount([Permission::ACTION_ALLOCATION_CREATE]);
$this->actingAs($user)->postJson($this->link($this->getAllocation($server), '/primary'))
->assertForbidden();
}
/**
* @param array $permissions
* @dataProvider deletePermissionsDataProvider
*/
public function testAllocationCanBeDeleted(array $permissions)
{
[$user, $server] = $this->generateTestAccount($permissions);
$allocation = $this->getAllocation($server);
$allocation2 = $this->getAllocation($server);
$allocation2->notes = 'Filled notes';
$allocation2->save();
$server->allocation_id = $allocation->id;
$server->save();
$this->actingAs($user)->deleteJson($this->link($allocation))
->assertStatus(Response::HTTP_BAD_REQUEST)
->assertJsonPath('errors.0.code', 'DisplayException')
->assertJsonPath('errors.0.detail', 'Cannot delete the primary allocation for a server.');
$this->actingAs($user)->deleteJson($this->link($allocation2))
->assertStatus(Response::HTTP_NO_CONTENT);
$server = $server->refresh();
$allocation2 = $allocation2->refresh();
$this->assertSame($allocation->id, $server->allocation_id);
$this->assertNull($allocation2->server_id);
$this->assertNull($allocation2->notes);
}
public function testAllocationCannotBeDeletedByInvalidUser()
{
[$user, $server] = $this->generateTestAccount();
$user2 = factory(User::class)->create();
$server->owner_id = $user2->id;
$server->save();
$this->actingAs($user)->deleteJson($this->link($this->getAllocation($server)))
->assertNotFound();
[$user, $server] = $this->generateTestAccount([Permission::ACTION_ALLOCATION_CREATE]);
$this->actingAs($user)->deleteJson($this->link($this->getAllocation($server)))
$this->actingAs($user)->postJson($this->link($server->allocation, '/primary'))
->assertForbidden();
}
@ -197,16 +144,4 @@ class NetworkAllocationControllerTest extends ClientApiIntegrationTestCase
{
return [[[]], [[Permission::ACTION_ALLOCATION_DELETE]]];
}
/**
* @param \Pterodactyl\Models\Server $server
* @return \Pterodactyl\Models\Allocation
*/
protected function getAllocation(Server $server): Allocation
{
return factory(Allocation::class)->create([
'server_id' => $server->id,
'node_id' => $server->node_id,
]);
}
}

View File

@ -0,0 +1,179 @@
<?php
namespace Pterodactyl\Tests\Integration\Services\Allocations;
use Exception;
use InvalidArgumentException;
use Pterodactyl\Models\Allocation;
use Pterodactyl\Tests\Integration\IntegrationTestCase;
use Pterodactyl\Services\Allocations\FindAssignableAllocationService;
use Pterodactyl\Exceptions\Service\Allocation\AutoAllocationNotEnabledException;
use Pterodactyl\Exceptions\Service\Allocation\NoAutoAllocationSpaceAvailableException;
class FindAssignableAllocationServiceTest extends IntegrationTestCase
{
/**
* Setup tests.
*/
public function setUp(): void
{
parent::setUp();
config()->set('pterodactyl.client_features.allocations.enabled', true);
config()->set('pterodactyl.client_features.allocations.range_start', 0);
config()->set('pterodactyl.client_features.allocations.range_end', 0);
}
/**
* Test that an unassigned allocation is prefered rather than creating an entirely new
* allocation for the server.
*/
public function testExistingAllocationIsPreferred()
{
$server = $this->createServerModel();
$created = factory(Allocation::class)->create([
'node_id' => $server->node_id,
'ip' => $server->allocation->ip,
]);
$response = $this->getService()->handle($server);
$this->assertSame($created->id, $response->id);
$this->assertSame($server->allocation->ip, $response->ip);
$this->assertSame($server->node_id, $response->node_id);
$this->assertSame($server->id, $response->server_id);
$this->assertNotSame($server->allocation_id, $response->id);
}
/**
* Test that a new allocation is created if there is not a free one available.
*/
public function testNewAllocationIsCreatedIfOneIsNotFound()
{
$server = $this->createServerModel();
config()->set('pterodactyl.client_features.allocations.range_start', 5000);
config()->set('pterodactyl.client_features.allocations.range_end', 5005);
$response = $this->getService()->handle($server);
$this->assertSame($server->id, $response->server_id);
$this->assertSame($server->allocation->ip, $response->ip);
$this->assertSame($server->node_id, $response->node_id);
$this->assertNotSame($server->allocation_id, $response->id);
$this->assertTrue($response->port >= 5000 && $response->port <= 5005);
}
/**
* Test that a currently assigned port is never assigned to a server.
*/
public function testOnlyPortNotInUseIsCreated()
{
$server = $this->createServerModel();
$server2 = $this->createServerModel(['node_id' => $server->node_id]);
config()->set('pterodactyl.client_features.allocations.range_start', 5000);
config()->set('pterodactyl.client_features.allocations.range_end', 5001);
factory(Allocation::class)->create([
'server_id' => $server2->id,
'node_id' => $server->node_id,
'ip' => $server->allocation->ip,
'port' => 5000,
]);
$response = $this->getService()->handle($server);
$this->assertSame(5001, $response->port);
}
public function testExceptionIsThrownIfNoMoreAllocationsCanBeCreatedInRange()
{
$server = $this->createServerModel();
$server2 = $this->createServerModel(['node_id' => $server->node_id]);
config()->set('pterodactyl.client_features.allocations.range_start', 5000);
config()->set('pterodactyl.client_features.allocations.range_end', 5005);
for ($i = 5000; $i <= 5005; $i++) {
factory(Allocation::class)->create([
'ip' => $server->allocation->ip,
'port' => $i,
'node_id' => $server->node_id,
'server_id' => $server2->id,
]);
}
$this->expectException(NoAutoAllocationSpaceAvailableException::class);
$this->expectExceptionMessage('Cannot assign additional allocation: no more space available on node.');
$this->getService()->handle($server);
}
/**
* Test that we only auto-allocate from the current server's IP address space, and not a random
* IP address available on that node.
*/
public function testExceptionIsThrownIfOnlyFreePortIsOnADifferentIp()
{
$server = $this->createServerModel();
factory(Allocation::class)->times(5)->create(['node_id' => $server->node_id]);
$this->expectException(NoAutoAllocationSpaceAvailableException::class);
$this->expectExceptionMessage('Cannot assign additional allocation: no more space available on node.');
$this->getService()->handle($server);
}
public function testExceptionIsThrownIfStartOrEndRangeIsNotDefined()
{
$server = $this->createServerModel();
$this->expectException(NoAutoAllocationSpaceAvailableException::class);
$this->expectExceptionMessage('Cannot assign additional allocation: no more space available on node.');
$this->getService()->handle($server);
}
public function testExceptionIsThrownIfStartOrEndRangeIsNotNumeric()
{
$server = $this->createServerModel();
config()->set('pterodactyl.client_features.allocations.range_start', 'hodor');
config()->set('pterodactyl.client_features.allocations.range_end', 10);
try {
$this->getService()->handle($server);
$this->assertTrue(false, 'This assertion should not be reached.');
} catch (Exception $exception) {
$this->assertInstanceOf(InvalidArgumentException::class, $exception);
$this->assertSame('Expected an integerish value. Got: string', $exception->getMessage());
}
config()->set('pterodactyl.client_features.allocations.range_start', 10);
config()->set('pterodactyl.client_features.allocations.range_end', 'hodor');
try {
$this->getService()->handle($server);
$this->assertTrue(false, 'This assertion should not be reached.');
} catch (Exception $exception) {
$this->assertInstanceOf(InvalidArgumentException::class, $exception);
$this->assertSame('Expected an integerish value. Got: string', $exception->getMessage());
}
}
public function testExceptionIsThrownIfFeatureIsNotEnabled()
{
config()->set('pterodactyl.client_features.allocations.enabled', false);
$server = $this->createServerModel();
$this->expectException(AutoAllocationNotEnabledException::class);
$this->getService()->handle($server);
}
/**
* @return \Pterodactyl\Services\Allocations\FindAssignableAllocationService
*/
private function getService()
{
return $this->app->make(FindAssignableAllocationService::class);
}
}

View File

@ -71,6 +71,8 @@ trait CreatesTestModels
$server = $factory->of(Server::class)->create($attributes);
Allocation::query()->where('id', $server->allocation_id)->update(['server_id' => $server->id]);
return Server::with([
'location', 'user', 'node', 'allocation', 'nest', 'egg',
])->findOrFail($server->id);