diff --git a/app/Exceptions/Service/Allocation/AutoAllocationNotEnabledException.php b/app/Exceptions/Service/Allocation/AutoAllocationNotEnabledException.php new file mode 100644 index 000000000..a593347e6 --- /dev/null +++ b/app/Exceptions/Service/Allocation/AutoAllocationNotEnabledException.php @@ -0,0 +1,18 @@ +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); } diff --git a/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php b/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php index a3f72972f..76f786cf9 100644 --- a/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php +++ b/app/Http/Requests/Admin/Settings/AdvancedSettingsFormRequest.php @@ -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', ]; } } diff --git a/app/Http/Requests/Api/Client/Servers/Network/NewAllocationRequest.php b/app/Http/Requests/Api/Client/Servers/Network/NewAllocationRequest.php new file mode 100644 index 000000000..7628afaaf --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Network/NewAllocationRequest.php @@ -0,0 +1,20 @@ +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; + } +} diff --git a/config/app.php b/config/app.php index f57036f95..f1fb42d83 100644 --- a/config/app.php +++ b/config/app.php @@ -9,7 +9,7 @@ return [ | change this value if you are not maintaining your own internal versions. */ - 'version' => 'canary', + 'version' => '1.0.1', /* |-------------------------------------------------------------------------- diff --git a/config/pterodactyl.php b/config/pterodactyl.php index aba0d729a..404f7aa34 100644 --- a/config/pterodactyl.php +++ b/config/pterodactyl.php @@ -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'), + ], ], /* diff --git a/resources/scripts/api/server/network/createServerAllocation.ts b/resources/scripts/api/server/network/createServerAllocation.ts new file mode 100644 index 000000000..3e94cf7f6 --- /dev/null +++ b/resources/scripts/api/server/network/createServerAllocation.ts @@ -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 => { + const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations`); + + return rawDataToServerAllocation(data); +}; diff --git a/resources/scripts/api/server/network/getServerAllocations.ts b/resources/scripts/api/server/network/getServerAllocations.ts deleted file mode 100644 index 7309bd266..000000000 --- a/resources/scripts/api/server/network/getServerAllocations.ts +++ /dev/null @@ -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 => { - const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`); - - return (data.data || []).map(rawDataToServerAllocation); -}; diff --git a/resources/scripts/api/swr/getServerAllocations.ts b/resources/scripts/api/swr/getServerAllocations.ts new file mode 100644 index 000000000..a5591a396 --- /dev/null +++ b/resources/scripts/api/swr/getServerAllocations.ts @@ -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([ 'server:allocations', uuid ], async () => { + const { data } = await http.get(`/api/client/servers/${uuid}/network/allocations`); + + return (data.data || []).map(rawDataToServerAllocation); + }, { revalidateOnFocus: false, revalidateOnMount: false }); +}; diff --git a/resources/scripts/components/elements/ConfirmationModal.tsx b/resources/scripts/components/elements/ConfirmationModal.tsx index 1b5b3ce1e..7233731f5 100644 --- a/resources/scripts/components/elements/ConfirmationModal.tsx +++ b/resources/scripts/components/elements/ConfirmationModal.tsx @@ -20,7 +20,7 @@ const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props)

{title}

{children}

- - + <> + + + + + + + }
diff --git a/resources/scripts/components/server/network/DeleteAllocationButton.tsx b/resources/scripts/components/server/network/DeleteAllocationButton.tsx new file mode 100644 index 000000000..e52ba3916 --- /dev/null +++ b/resources/scripts/components/server/network/DeleteAllocationButton.tsx @@ -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 ( + <> + setConfirm(false)} + > + This allocation will be immediately removed from your server. Are you sure you want to continue? + + + + ); +}; + +export default DeleteAllocationButton; diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx index 1cbab974d..8583b137a 100644 --- a/resources/scripts/components/server/network/NetworkContainer.tsx +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -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(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 ( {!data ? : - data.map(allocation => ( - - )) + <> + { + data.map(allocation => ( + + )) + } + + +
+

+ You are currently using {data.length} of {allocationLimit} allowed allocations for this + server. +

+ {allocationLimit > data.length && + + } +
+
+ }
); diff --git a/resources/views/admin/servers/view/build.blade.php b/resources/views/admin/servers/view/build.blade.php index e79c323da..36044ba22 100644 --- a/resources/views/admin/servers/view/build.blade.php +++ b/resources/views/admin/servers/view/build.blade.php @@ -115,7 +115,7 @@
-

This feature is not currently implemented. The total number of allocations a user is allowed to create for this server.

+

The total number of allocations a user is allowed to create for this server.

diff --git a/resources/views/admin/settings/advanced.blade.php b/resources/views/admin/settings/advanced.blade.php index fd9d7f824..b903b2435 100644 --- a/resources/views/admin/settings/advanced.blade.php +++ b/resources/views/admin/settings/advanced.blade.php @@ -82,6 +82,62 @@
+
+
+

Console

+
+
+
+
+ +
+ +

The number of messages to be pushed to the console per frequency tick.

+
+
+
+ +
+ +

The amount of time in milliseconds between each console message sending tick.

+
+
+
+
+
+
+
+

Automatic Allocation Creation

+
+
+
+
+ +
+ +

If enabled users will have the option to automatically create new allocations for their server via the frontend.

+
+
+
+ +
+ +

The starting port in the range that can be automatically allocated.

+
+
+
+ +
+ +

The ending port in the range that can be automatically allocated.

+
+
+
+
+