diff --git a/app/Http/Controllers/Admin/DatabaseController.php b/app/Http/Controllers/Admin/DatabaseController.php index 67ed1bd68..c825cd4fa 100644 --- a/app/Http/Controllers/Admin/DatabaseController.php +++ b/app/Http/Controllers/Admin/DatabaseController.php @@ -12,13 +12,11 @@ use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; - -use PDO; -use PDOException; use Pterodactyl\Services\Databases\Hosts\HostUpdateService; use Pterodactyl\Http\Requests\Admin\DatabaseHostFormRequest; use Pterodactyl\Services\Databases\Hosts\HostCreationService; use Pterodactyl\Services\Databases\Hosts\HostDeletionService; +use Pterodactyl\Services\Databases\Drivers\DatabaseDriverManager; use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface; use Pterodactyl\Contracts\Repository\LocationRepositoryInterface; use Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface; @@ -35,6 +33,7 @@ public function __construct( private HostCreationService $creationService, private HostDeletionService $deletionService, private HostUpdateService $updateService, + private DatabaseDriverManager $drivers, private LocationRepositoryInterface $locationRepository, private ViewFactory $view, ) {} @@ -74,15 +73,11 @@ public function create(DatabaseHostFormRequest $request): RedirectResponse try { $host = $this->creationService->handle($request->normalize()); } catch (\Exception $exception) { - if ($exception instanceof \PDOException || $exception->getPrevious() instanceof \PDOException) { - $this->alert->danger( - sprintf('There was an error while trying to connect to the host or while executing a query: "%s"', $exception->getMessage()) - )->flash(); - - return redirect()->route('admin.databases')->withInput($request->validated()); - } else { - throw $exception; - } + $this->alert->danger( + sprintf('There was an error while trying to connect to the host or while executing a query: "%s"', $exception->getMessage()) + )->flash(); + + return redirect()->route('admin.databases')->withInput($request->validated()); } $this->alert->success('Successfully created a new database host on the system.')->flash(); @@ -103,17 +98,11 @@ public function update(DatabaseHostFormRequest $request, DatabaseHost $host): Re $this->updateService->handle($host->id, $request->normalize()); $this->alert->success('Database host was updated successfully.')->flash(); } catch (\Exception $exception) { - // Catch any SQL related exceptions and display them back to the user, otherwise just - // throw the exception like normal and move on with it. - if ($exception instanceof \PDOException || $exception->getPrevious() instanceof \PDOException) { - $this->alert->danger( - sprintf('There was an error while trying to connect to the host or while executing a query: "%s"', $exception->getMessage()) - )->flash(); - - return $redirect->withInput($request->normalize()); - } else { - throw $exception; - } + $this->alert->danger( + sprintf('There was an error while trying to connect to the host or while executing a query: "%s"', $exception->getMessage()) + )->flash(); + + return $redirect->withInput($request->normalize()); } return $redirect; @@ -144,53 +133,35 @@ public function testConnection(Request $request): JsonResponse 'port' => 'required|integer|min:1|max:65535', 'username' => 'required|string', 'password' => 'required|string', - ]); - Log::error("TestConnection", [ - "\nhost" => $request->host, - "\nport" => $request->port, - "\nusername" => $request->username, - "\npassword" => $request->password + 'type' => 'required|string|in:mysql,postgresql,redis,mongodb', ]); try { - $dsn = "mysql:host={$request->input('host')};port={$request->input('port')};charset=utf8"; - - $pdo = new PDO($dsn, $request->input('username'), $request->input('password'), [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_TIMEOUT => 5, // 5 second timeout - ]); - - // Test basic query - $version = $pdo->query('SELECT VERSION() as version')->fetchColumn(); + $host = new DatabaseHost(); + $host->forceFill([ + 'host' => $request->input('host'), + 'port' => $request->input('port'), + 'username' => $request->input('username'), + 'password' => app(\Illuminate\Contracts\Encryption\Encrypter::class)->encrypt($request->input('password')), + 'type' => $request->input('type'), + ])->skipValidation(); - // Test GRANT permissions (this is what Pterodactyl needs) - $grants = $pdo->query('SHOW GRANTS FOR CURRENT_USER()')->fetchAll(PDO::FETCH_COLUMN); - - $hasGrantOption = false; - foreach ($grants as $grant) { - if (stripos($grant, 'GRANT OPTION') !== false) { - $hasGrantOption = true; - break; - } - } - - $message = "Successfully connected to MySQL server (Version: {$version})."; - if (!$hasGrantOption) { - $message .= " Warning: The user appears to lack GRANT OPTION permission which is required for creating databases and users."; - } + $details = $this->drivers->driverFor($host)->testConnection($host); return response()->json([ 'success' => true, - 'message' => $message, - 'version' => $version, - 'has_grant_option' => $hasGrantOption + 'message' => $details['message'] ?? 'Successfully connected.', + 'version' => $details['version'] ?? null, + 'has_grant_option' => $details['has_grant_option'] ?? null, ]); - } catch (PDOException $e) { - return response()->json([ - 'success' => false, - 'message' => 'Connection failed: ' . $e->getMessage() - ], 422); } catch (\Exception $e) { + Log::warning('Database host connection test failed.', [ + 'type' => $request->input('type'), + 'host' => $request->input('host'), + 'port' => $request->input('port'), + 'error' => $e->getMessage(), + ]); + return response()->json([ 'success' => false, 'message' => 'Error: ' . $e->getMessage() diff --git a/app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php b/app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php index 85bc3291e..45020fac8 100644 --- a/app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php +++ b/app/Http/Controllers/Api/Client/Servers/Elytra/DatabaseController.php @@ -3,8 +3,8 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Elytra; use Illuminate\Http\Response; -use Pterodactyl\Models\Server; use Pterodactyl\Models\Database; +use Pterodactyl\Models\Server; use Pterodactyl\Facades\Activity; use Pterodactyl\Services\Databases\DatabasePasswordService; use Pterodactyl\Transformers\Api\Client\DatabaseTransformer; @@ -36,6 +36,9 @@ public function index(GetDatabasesRequest $request, Server $server): array { return $this->fractal->collection($server->databases) ->transformWith($this->getTransformer(DatabaseTransformer::class)) + ->addMeta([ + 'available_database_types' => $this->deployDatabaseService->availableTypeMetadata($server)->all(), + ]) ->toArray(); } diff --git a/app/Http/Controllers/Api/Client/Servers/Wings/DatabaseController.php b/app/Http/Controllers/Api/Client/Servers/Wings/DatabaseController.php index a3b8de6b6..37c70177a 100644 --- a/app/Http/Controllers/Api/Client/Servers/Wings/DatabaseController.php +++ b/app/Http/Controllers/Api/Client/Servers/Wings/DatabaseController.php @@ -3,8 +3,8 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers\Wings; use Illuminate\Http\Response; -use Pterodactyl\Models\Server; use Pterodactyl\Models\Database; +use Pterodactyl\Models\Server; use Pterodactyl\Facades\Activity; use Pterodactyl\Services\Databases\DatabasePasswordService; use Pterodactyl\Transformers\Api\Client\DatabaseTransformer; @@ -36,6 +36,9 @@ public function index(GetDatabasesRequest $request, Server $server): array { return $this->fractal->collection($server->databases) ->transformWith($this->getTransformer(DatabaseTransformer::class)) + ->addMeta([ + 'available_database_types' => $this->deployDatabaseService->availableTypeMetadata($server)->all(), + ]) ->toArray(); } diff --git a/app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php b/app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php index be4f4a719..3297fee49 100644 --- a/app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php @@ -6,6 +6,7 @@ use Pterodactyl\Models\Server; use Illuminate\Validation\Rule; use Pterodactyl\Models\Database; +use Pterodactyl\Models\DatabaseHost; use Pterodactyl\Models\Permission; use Illuminate\Database\Query\Builder; use Pterodactyl\Contracts\Http\ClientPermissionsRequest; @@ -40,6 +41,7 @@ public function rules(): array }), ], 'remote' => Database::getRulesForField('remote'), + 'database_type' => ['required', 'string', Rule::in(array_keys(DatabaseHost::typeLabels()))], ]; } diff --git a/app/Models/Database.php b/app/Models/Database.php index ceaa01a0d..84fb7ab10 100644 --- a/app/Models/Database.php +++ b/app/Models/Database.php @@ -15,7 +15,9 @@ * @property string $username * @property string $remote * @property string $password + * @property string $type * @property int $max_connections + * @property array|null $connection_details * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property Server $server @@ -52,7 +54,9 @@ class Database extends Model 'username', 'password', 'remote', + 'type', 'max_connections', + 'connection_details', ]; /** @@ -62,6 +66,7 @@ class Database extends Model 'server_id' => 'integer', 'database_host_id' => 'integer', 'max_connections' => 'integer', + 'connection_details' => 'array', ]; public static array $validationRules = [ @@ -71,7 +76,9 @@ class Database extends Model 'username' => 'string|alpha_dash|between:3,100', 'max_connections' => 'nullable|integer', 'remote' => 'required|string|regex:/^[\w\-\/.%:]+$/', + 'type' => 'required|string|in:mysql,postgresql,redis,mongodb', 'password' => 'string', + 'connection_details' => 'nullable|array', ]; public function getRouteKeyName(): string diff --git a/app/Models/DatabaseHost.php b/app/Models/DatabaseHost.php index b960044cc..3ea938464 100644 --- a/app/Models/DatabaseHost.php +++ b/app/Models/DatabaseHost.php @@ -13,6 +13,7 @@ * @property int $port * @property string $username * @property string $password + * @property string $type * @property int|null $max_databases * @property int|null $node_id * @property \Carbon\CarbonImmutable $created_at @@ -20,6 +21,11 @@ */ class DatabaseHost extends Model { + public const TYPE_MYSQL = 'mysql'; + public const TYPE_POSTGRESQL = 'postgresql'; + public const TYPE_REDIS = 'redis'; + public const TYPE_MONGODB = 'mongodb'; + /** @use HasFactory<\Database\Factories\DatabaseHostFactory> */ use HasFactory; @@ -50,6 +56,7 @@ class DatabaseHost extends Model 'port', 'username', 'password', + 'type', 'max_databases', 'node_id', ]; @@ -72,9 +79,20 @@ class DatabaseHost extends Model 'port' => 'required|numeric|between:1,65535', 'username' => 'required|string|max:32', 'password' => 'nullable|string', + 'type' => 'required|string|in:mysql,postgresql,redis,mongodb', 'node_id' => 'sometimes|nullable|integer|exists:nodes,id', ]; + public static function typeLabels(): array + { + return [ + self::TYPE_MYSQL => 'MariaDB/MySQL', + self::TYPE_POSTGRESQL => 'PostgreSQL', + self::TYPE_REDIS => 'Redis', + self::TYPE_MONGODB => 'MongoDB', + ]; + } + /** * Gets the node associated with a database host. */ diff --git a/app/Services/Databases/DatabaseManagementService.php b/app/Services/Databases/DatabaseManagementService.php index 137cba0b0..86a0122dc 100644 --- a/app/Services/Databases/DatabaseManagementService.php +++ b/app/Services/Databases/DatabaseManagementService.php @@ -5,12 +5,12 @@ use Exception; use Pterodactyl\Models\Server; use Pterodactyl\Models\Database; +use Pterodactyl\Models\DatabaseHost; use Pterodactyl\Helpers\Utilities; use Illuminate\Database\ConnectionInterface; use Illuminate\Contracts\Encryption\Encrypter; -use Pterodactyl\Extensions\DynamicDatabaseConnection; -use Pterodactyl\Repositories\Eloquent\DatabaseRepository; use Pterodactyl\Exceptions\Repository\DuplicateDatabaseNameException; +use Pterodactyl\Services\Databases\Drivers\DatabaseDriverManager; use Pterodactyl\Exceptions\Service\Database\TooManyDatabasesException; use Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException; @@ -34,9 +34,8 @@ class DatabaseManagementService public function __construct( protected ConnectionInterface $connection, - protected DynamicDatabaseConnection $dynamic, protected Encrypter $encrypter, - protected DatabaseRepository $repository, + protected DatabaseDriverManager $drivers, ) {} /** @@ -91,40 +90,38 @@ public function create(Server $server, array $data): Database throw new \InvalidArgumentException('The database name passed to DatabaseManagementService::handle MUST be prefixed with "s{server_id}_".'); } + /** @var DatabaseHost $host */ + $host = DatabaseHost::query()->findOrFail($data['database_host_id']); + $data = array_merge($data, [ 'server_id' => $server->id, 'username' => sprintf('u%d_%s', $server->id, str_random(10)), 'password' => $this->encrypter->encrypt( Utilities::randomStringWithSpecialCharacters(24) ), + 'type' => $host->type, + 'connection_details' => null, ]); $database = null; + $plainPassword = $this->encrypter->decrypt($data['password']); + $driver = $this->drivers->driverFor($host); try { - return $this->connection->transaction(function () use ($data, &$database) { + return $this->connection->transaction(function () use ($data, $driver, $host, $plainPassword, &$database) { $database = $this->createModel($data); + $connectionDetails = $driver->create($host, $database, $plainPassword); - $this->dynamic->set('dynamic', $data['database_host_id']); - - $this->repository->createDatabase($database->database); - $this->repository->createUser( - $database->username, - $database->remote, - $this->encrypter->decrypt($database->password), - $database->max_connections - ); - $this->repository->assignUserToDatabase($database->database, $database->username, $database->remote); - $this->repository->flush(); + if ($connectionDetails !== []) { + $database->forceFill(['connection_details' => $connectionDetails])->saveOrFail(); + } return $database; }); } catch (\Exception $exception) { try { if ($database instanceof Database) { - $this->repository->dropDatabase($database->database); - $this->repository->dropUser($database->username, $database->remote); - $this->repository->flush(); + $driver->delete($host, $database); } } catch (\Exception $deletionException) { // Do nothing here. We've already encountered an issue before this point so no @@ -142,11 +139,8 @@ public function create(Server $server, array $data): Database */ public function delete(Database $database): ?bool { - $this->dynamic->set('dynamic', $database->database_host_id); - - $this->repository->dropDatabase($database->database); - $this->repository->dropUser($database->username, $database->remote); - $this->repository->flush(); + $database->loadMissing('host'); + $this->drivers->driverFor($database->host)->delete($database->host, $database); return $database->delete(); } diff --git a/app/Services/Databases/DatabasePasswordService.php b/app/Services/Databases/DatabasePasswordService.php index 66c395df4..0fee1414e 100644 --- a/app/Services/Databases/DatabasePasswordService.php +++ b/app/Services/Databases/DatabasePasswordService.php @@ -6,8 +6,7 @@ use Pterodactyl\Helpers\Utilities; use Illuminate\Database\ConnectionInterface; use Illuminate\Contracts\Encryption\Encrypter; -use Pterodactyl\Extensions\DynamicDatabaseConnection; -use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface; +use Pterodactyl\Services\Databases\Drivers\DatabaseDriverManager; class DatabasePasswordService { @@ -16,9 +15,8 @@ class DatabasePasswordService */ public function __construct( private ConnectionInterface $connection, - private DynamicDatabaseConnection $dynamic, private Encrypter $encrypter, - private DatabaseRepositoryInterface $repository, + private DatabaseDriverManager $drivers, ) { } @@ -29,19 +27,25 @@ public function __construct( */ public function handle(Database|int $database): string { - $password = Utilities::randomStringWithSpecialCharacters(24); + if (is_int($database)) { + $database = Database::query()->findOrFail($database); + } - $this->connection->transaction(function () use ($database, $password) { - $this->dynamic->set('dynamic', $database->database_host_id); + $password = Utilities::randomStringWithSpecialCharacters(24); + $database->loadMissing('host'); + $driver = $this->drivers->driverFor($database->host); - $this->repository->withoutFreshModel()->update($database->id, [ + $this->connection->transaction(function () use ($database, $password, $driver) { + $updates = [ 'password' => $this->encrypter->encrypt($password), - ]); + ]; + + $connectionDetails = $driver->rotatePassword($database->host, $database, $password); + if ($connectionDetails !== []) { + $updates['connection_details'] = $connectionDetails; + } - $this->repository->dropUser($database->username, $database->remote); - $this->repository->createUser($database->username, $database->remote, $password, $database->max_connections); - $this->repository->assignUserToDatabase($database->database, $database->username, $database->remote); - $this->repository->flush(); + $database->forceFill($updates)->saveOrFail(); }); return $password; diff --git a/app/Services/Databases/DeployServerDatabaseService.php b/app/Services/Databases/DeployServerDatabaseService.php index e22eba51d..ffee38b47 100644 --- a/app/Services/Databases/DeployServerDatabaseService.php +++ b/app/Services/Databases/DeployServerDatabaseService.php @@ -3,17 +3,22 @@ namespace Pterodactyl\Services\Databases; use Webmozart\Assert\Assert; -use Pterodactyl\Models\Server; -use Pterodactyl\Models\Database; +use Illuminate\Support\Collection; use Pterodactyl\Models\DatabaseHost; +use Pterodactyl\Models\Database; +use Pterodactyl\Models\Server; use Pterodactyl\Exceptions\Service\Database\NoSuitableDatabaseHostException; +use Pterodactyl\Services\Databases\Drivers\DatabaseDriverManager; class DeployServerDatabaseService { /** * DeployServerDatabaseService constructor. */ - public function __construct(private DatabaseManagementService $managementService) + public function __construct( + private DatabaseManagementService $managementService, + private DatabaseDriverManager $drivers, + ) { } @@ -25,9 +30,11 @@ public function __construct(private DatabaseManagementService $managementService public function handle(Server $server, array $data): Database { Assert::notEmpty($data['database'] ?? null); - Assert::notEmpty($data['remote'] ?? null); + Assert::notEmpty($data['remote'] ?? '%'); - $hosts = DatabaseHost::query()->get()->toBase(); + $type = $data['database_type'] ?? DatabaseHost::TYPE_MYSQL; + + $hosts = DatabaseHost::query()->where('type', $type)->get()->toBase(); if ($hosts->isEmpty()) { throw new NoSuitableDatabaseHostException(); } else { @@ -43,7 +50,36 @@ public function handle(Server $server, array $data): Database ? $hosts->random()->id : $nodeHosts->random()->id, 'database' => DatabaseManagementService::generateUniqueDatabaseName($data['database'], $server->id), - 'remote' => $data['remote'], + 'remote' => $data['remote'] ?? '%', ]); } + + public function availableTypes(Server $server): Collection + { + $hosts = DatabaseHost::query()->get()->toBase(); + $nodeHosts = $hosts->where('node_id', $server->node_id)->toBase(); + + if ($nodeHosts->isNotEmpty()) { + return $nodeHosts->pluck('type')->unique()->values(); + } + + if (!config('pterodactyl.client_features.databases.allow_random')) { + return collect(); + } + + return $hosts->pluck('type')->unique()->values(); + } + + public function availableTypeMetadata(Server $server): Collection + { + return $this->availableTypes($server)->map(function (string $type) { + $driver = $this->drivers->driverFor($type); + + return [ + 'value' => $type, + 'label' => DatabaseHost::typeLabels()[$type] ?? $type, + 'supports_remote_connections' => $driver->supportsRemoteConnections(), + ]; + })->values(); + } } diff --git a/app/Services/Databases/Drivers/DatabaseDriverInterface.php b/app/Services/Databases/Drivers/DatabaseDriverInterface.php new file mode 100644 index 000000000..269f8e9e6 --- /dev/null +++ b/app/Services/Databases/Drivers/DatabaseDriverInterface.php @@ -0,0 +1,39 @@ + + */ + public function testConnection(DatabaseHost $host): array; + + /** + * Provision database resources for the given record. + * + * @return array + */ + public function create(DatabaseHost $host, Database $database, string $password): array; + + /** + * Delete any provisioned resources for the given record. + */ + public function delete(DatabaseHost $host, Database $database): void; + + /** + * Rotate credentials for the given record. + * + * @return array + */ + public function rotatePassword(DatabaseHost $host, Database $database, string $password): array; +} diff --git a/app/Services/Databases/Drivers/DatabaseDriverManager.php b/app/Services/Databases/Drivers/DatabaseDriverManager.php new file mode 100644 index 000000000..a3fcb380c --- /dev/null +++ b/app/Services/Databases/Drivers/DatabaseDriverManager.php @@ -0,0 +1,37 @@ + */ + private array $drivers; + + public function __construct( + MysqlDatabaseDriver $mysql, + PostgresqlDatabaseDriver $postgresql, + RedisDatabaseDriver $redis, + MongodbDatabaseDriver $mongodb, + ) { + $this->drivers = [ + $mysql->type() => $mysql, + $postgresql->type() => $postgresql, + $redis->type() => $redis, + $mongodb->type() => $mongodb, + ]; + } + + public function driverFor(DatabaseHost|string $host): DatabaseDriverInterface + { + $type = $host instanceof DatabaseHost ? $host->type : $host; + + if (!isset($this->drivers[$type])) { + throw new InvalidArgumentException(sprintf('Unsupported database driver type "%s".', $type)); + } + + return $this->drivers[$type]; + } +} diff --git a/app/Services/Databases/Drivers/MongodbDatabaseDriver.php b/app/Services/Databases/Drivers/MongodbDatabaseDriver.php new file mode 100644 index 000000000..6db537e09 --- /dev/null +++ b/app/Services/Databases/Drivers/MongodbDatabaseDriver.php @@ -0,0 +1,110 @@ +manager($host)->executeCommand('admin', new Command(['buildInfo' => 1]))->toArray()); + $version = (string) ($result->version ?? 'unknown'); + + return [ + 'version' => $version, + 'message' => sprintf('Successfully connected to MongoDB server (Version: %s).', $version), + ]; + } + + public function create(DatabaseHost $host, Database $database, string $password): array + { + $command = [ + 'createUser' => $database->username, + 'pwd' => $password, + 'roles' => [['role' => 'dbOwner', 'db' => $database->database]], + 'mechanisms' => ['SCRAM-SHA-256'], + ]; + + if ($restrictions = $this->authenticationRestrictions($database->remote)) { + $command['authenticationRestrictions'] = $restrictions; + } + + $this->manager($host)->executeCommand($database->database, new Command($command)); + + return []; + } + + public function delete(DatabaseHost $host, Database $database): void + { + $this->manager($host)->executeCommand($database->database, new Command([ + 'dropUser' => $database->username, + ])); + } + + public function rotatePassword(DatabaseHost $host, Database $database, string $password): array + { + $command = [ + 'updateUser' => $database->username, + 'pwd' => $password, + ]; + + if ($restrictions = $this->authenticationRestrictions($database->remote)) { + $command['authenticationRestrictions'] = $restrictions; + } + + $this->manager($host)->executeCommand($database->database, new Command($command)); + + return []; + } + + /** + * @return array}>|null + */ + private function authenticationRestrictions(string $remote): ?array + { + $sources = array_values(array_filter(array_map('trim', explode(',', $remote)))); + + if ($sources === [] || array_intersect($sources, ['%', '*']) !== []) { + return null; + } + + return [[ + 'clientSource' => $sources, + ]]; + } + + private function manager(DatabaseHost $host): Manager + { + if (!class_exists(Manager::class)) { + throw new RuntimeException('The MongoDB PHP extension is required to manage MongoDB database hosts.'); + } + + return new Manager(sprintf( + 'mongodb://%s:%s@%s:%d/?authSource=admin', + rawurlencode($host->username), + rawurlencode($this->encrypter->decrypt($host->password)), + $host->host, + $host->port + )); + } +} diff --git a/app/Services/Databases/Drivers/MysqlDatabaseDriver.php b/app/Services/Databases/Drivers/MysqlDatabaseDriver.php new file mode 100644 index 000000000..d454e9e21 --- /dev/null +++ b/app/Services/Databases/Drivers/MysqlDatabaseDriver.php @@ -0,0 +1,91 @@ +dynamic->set('dynamic', $host); + + /** @var PDO $pdo */ + $pdo = $this->databaseManager->connection('dynamic')->getPdo(); + $version = (string) $pdo->query('SELECT VERSION()')->fetchColumn(); + $grants = $pdo->query('SHOW GRANTS FOR CURRENT_USER()')->fetchAll(PDO::FETCH_COLUMN); + + $hasGrantOption = false; + foreach ($grants as $grant) { + if (stripos((string) $grant, 'GRANT OPTION') !== false) { + $hasGrantOption = true; + break; + } + } + + return [ + 'version' => $version, + 'has_grant_option' => $hasGrantOption, + 'message' => $hasGrantOption + ? sprintf('Successfully connected to MariaDB/MySQL server (Version: %s).', $version) + : sprintf('Connected to MariaDB/MySQL server (Version: %s), but the user appears to be missing GRANT OPTION.', $version), + ]; + } + + public function create(DatabaseHost $host, Database $database, string $password): array + { + $this->dynamic->set('dynamic', $host); + + $this->repository->createDatabase($database->database); + $this->repository->createUser($database->username, $database->remote, $password, $database->max_connections); + $this->repository->assignUserToDatabase($database->database, $database->username, $database->remote); + $this->repository->flush(); + + return []; + } + + public function delete(DatabaseHost $host, Database $database): void + { + $this->dynamic->set('dynamic', $host); + + $this->repository->dropDatabase($database->database); + $this->repository->dropUser($database->username, $database->remote); + $this->repository->flush(); + } + + public function rotatePassword(DatabaseHost $host, Database $database, string $password): array + { + $this->dynamic->set('dynamic', $host); + + $this->repository->dropUser($database->username, $database->remote); + $this->repository->createUser($database->username, $database->remote, $password, $database->max_connections); + $this->repository->assignUserToDatabase($database->database, $database->username, $database->remote); + $this->repository->flush(); + + return []; + } +} diff --git a/app/Services/Databases/Drivers/PostgresqlDatabaseDriver.php b/app/Services/Databases/Drivers/PostgresqlDatabaseDriver.php new file mode 100644 index 000000000..8325c83da --- /dev/null +++ b/app/Services/Databases/Drivers/PostgresqlDatabaseDriver.php @@ -0,0 +1,101 @@ +connect($host); + $version = (string) $pdo->query('SELECT version()')->fetchColumn(); + + return [ + 'version' => $version, + 'message' => sprintf('Successfully connected to PostgreSQL server (%s).', $version), + ]; + } + + public function create(DatabaseHost $host, Database $database, string $password): array + { + $pdo = $this->connect($host); + + $pdo->exec(sprintf( + 'CREATE ROLE %s WITH LOGIN PASSWORD %s%s', + $this->quoteIdentifier($database->username), + $pdo->quote($password), + $database->max_connections ? sprintf(' CONNECTION LIMIT %d', $database->max_connections) : '' + )); + $pdo->exec(sprintf('CREATE DATABASE %s', $this->quoteIdentifier($database->database))); + $pdo->exec(sprintf( + 'ALTER DATABASE %s OWNER TO %s', + $this->quoteIdentifier($database->database), + $this->quoteIdentifier($database->username) + )); + $pdo->exec(sprintf( + 'GRANT ALL PRIVILEGES ON DATABASE %s TO %s', + $this->quoteIdentifier($database->database), + $this->quoteIdentifier($database->username) + )); + + return []; + } + + public function delete(DatabaseHost $host, Database $database): void + { + $pdo = $this->connect($host); + + $pdo->exec(sprintf( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid()", + $pdo->quote($database->database) + )); + $pdo->exec(sprintf('DROP DATABASE IF EXISTS %s', $this->quoteIdentifier($database->database))); + $pdo->exec(sprintf('DROP ROLE IF EXISTS %s', $this->quoteIdentifier($database->username))); + } + + public function rotatePassword(DatabaseHost $host, Database $database, string $password): array + { + $pdo = $this->connect($host); + + $pdo->exec(sprintf( + 'ALTER ROLE %s WITH PASSWORD %s', + $this->quoteIdentifier($database->username), + $pdo->quote($password) + )); + + return []; + } + + private function connect(DatabaseHost $host): PDO + { + return new PDO( + sprintf('pgsql:host=%s;port=%d;dbname=%s', $host->host, $host->port, self::MAINTENANCE_DATABASE), + $host->username, + app(\Illuminate\Contracts\Encryption\Encrypter::class)->decrypt($host->password), + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 5, + ] + ); + } + + private function quoteIdentifier(string $value): string + { + return '"' . str_replace('"', '""', $value) . '"'; + } +} diff --git a/app/Services/Databases/Drivers/RedisDatabaseDriver.php b/app/Services/Databases/Drivers/RedisDatabaseDriver.php new file mode 100644 index 000000000..c433c7eb7 --- /dev/null +++ b/app/Services/Databases/Drivers/RedisDatabaseDriver.php @@ -0,0 +1,99 @@ +client($host); + $info = $client->info('server'); + $version = (string) ($info['Server']['redis_version'] ?? $info['redis_version'] ?? 'unknown'); + + return [ + 'version' => $version, + 'message' => sprintf('Successfully connected to Redis server (Version: %s).', $version), + ]; + } + + public function create(DatabaseHost $host, Database $database, string $password): array + { + $client = $this->client($host); + $prefix = $database->database . ':*'; + + $client->executeRaw([ + 'ACL', 'SETUSER', $database->username, + 'on', + '>' . $password, + '~' . $prefix, + '&' . $prefix, + '+@all', + '-@dangerous', + ]); + + return [ + 'key_prefix' => rtrim($prefix, '*'), + ]; + } + + public function delete(DatabaseHost $host, Database $database): void + { + $client = $this->client($host); + $client->executeRaw(['ACL', 'DELUSER', $database->username]); + } + + public function rotatePassword(DatabaseHost $host, Database $database, string $password): array + { + $client = $this->client($host); + + $client->executeRaw([ + 'ACL', 'SETUSER', $database->username, + 'on', + 'resetpass', + '>' . $password, + ]); + + return [ + 'key_prefix' => $database->connection_details['key_prefix'] ?? ($database->database . ':'), + ]; + } + + private function client(DatabaseHost $host): Client + { + $parameters = [ + 'scheme' => 'tcp', + 'host' => $host->host, + 'port' => $host->port, + ]; + + if ($host->username !== '') { + $parameters['username'] = $host->username; + } + + $password = $this->encrypter->decrypt($host->password); + if ($password !== '') { + $parameters['password'] = $password; + } + + return new Client($parameters); + } +} diff --git a/app/Services/Databases/Hosts/HostCreationService.php b/app/Services/Databases/Hosts/HostCreationService.php index 0eecd7971..0c4a23a37 100644 --- a/app/Services/Databases/Hosts/HostCreationService.php +++ b/app/Services/Databases/Hosts/HostCreationService.php @@ -6,7 +6,7 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Database\ConnectionInterface; use Illuminate\Contracts\Encryption\Encrypter; -use Pterodactyl\Extensions\DynamicDatabaseConnection; +use Pterodactyl\Services\Databases\Drivers\DatabaseDriverManager; use Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface; class HostCreationService @@ -16,9 +16,8 @@ class HostCreationService */ public function __construct( private ConnectionInterface $connection, - private DatabaseManager $databaseManager, - private DynamicDatabaseConnection $dynamic, private Encrypter $encrypter, + private DatabaseDriverManager $drivers, private DatabaseHostRepositoryInterface $repository, ) { } @@ -37,13 +36,13 @@ public function handle(array $data): DatabaseHost 'host' => array_get($data, 'host'), 'port' => array_get($data, 'port'), 'username' => array_get($data, 'username'), + 'type' => array_get($data, 'type', DatabaseHost::TYPE_MYSQL), 'max_databases' => null, 'node_id' => array_get($data, 'node_id'), ]); // Confirm access using the provided credentials before saving data. - $this->dynamic->set('dynamic', $host); - $this->databaseManager->connection('dynamic')->select('SELECT 1 FROM dual'); + $this->drivers->driverFor($host)->testConnection($host); return $host; }); diff --git a/app/Services/Databases/Hosts/HostUpdateService.php b/app/Services/Databases/Hosts/HostUpdateService.php index e2efd46fc..b9a45a9f6 100644 --- a/app/Services/Databases/Hosts/HostUpdateService.php +++ b/app/Services/Databases/Hosts/HostUpdateService.php @@ -6,7 +6,7 @@ use Illuminate\Database\DatabaseManager; use Illuminate\Database\ConnectionInterface; use Illuminate\Contracts\Encryption\Encrypter; -use Pterodactyl\Extensions\DynamicDatabaseConnection; +use Pterodactyl\Services\Databases\Drivers\DatabaseDriverManager; use Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface; class HostUpdateService @@ -16,9 +16,8 @@ class HostUpdateService */ public function __construct( private ConnectionInterface $connection, - private DatabaseManager $databaseManager, - private DynamicDatabaseConnection $dynamic, private Encrypter $encrypter, + private DatabaseDriverManager $drivers, private DatabaseHostRepositoryInterface $repository, ) { } @@ -38,8 +37,7 @@ public function handle(int $hostId, array $data): DatabaseHost return $this->connection->transaction(function () use ($data, $hostId) { $host = $this->repository->update($hostId, $data); - $this->dynamic->set('dynamic', $host); - $this->databaseManager->connection('dynamic')->select('SELECT 1 FROM dual'); + $this->drivers->driverFor($host)->testConnection($host); return $host; }); diff --git a/app/Transformers/Api/Application/DatabaseHostTransformer.php b/app/Transformers/Api/Application/DatabaseHostTransformer.php index 019fdf261..1bba25d8d 100644 --- a/app/Transformers/Api/Application/DatabaseHostTransformer.php +++ b/app/Transformers/Api/Application/DatabaseHostTransformer.php @@ -30,6 +30,7 @@ public function transform(DatabaseHost $model): array return [ 'id' => $model->id, 'name' => $model->name, + 'type' => $model->type, 'host' => $model->host, 'port' => $model->port, 'username' => $model->username, diff --git a/app/Transformers/Api/Application/ServerDatabaseTransformer.php b/app/Transformers/Api/Application/ServerDatabaseTransformer.php index 2590482d9..1f2f569c2 100644 --- a/app/Transformers/Api/Application/ServerDatabaseTransformer.php +++ b/app/Transformers/Api/Application/ServerDatabaseTransformer.php @@ -43,7 +43,9 @@ public function transform(Database $model): array 'database' => $model->database, 'username' => $model->username, 'remote' => $model->remote, + 'type' => $model->type, 'max_connections' => $model->max_connections, + 'connection_details' => $model->connection_details, 'created_at' => $model->created_at->toAtomString(), 'updated_at' => $model->updated_at->toAtomString(), ]; diff --git a/app/Transformers/Api/Client/DatabaseTransformer.php b/app/Transformers/Api/Client/DatabaseTransformer.php index 23e966637..7a5502e23 100644 --- a/app/Transformers/Api/Client/DatabaseTransformer.php +++ b/app/Transformers/Api/Client/DatabaseTransformer.php @@ -37,6 +37,7 @@ public function transform(Database $model): array return [ 'id' => $this->hashids->encode($model->id), + 'type' => $model->type, 'host' => [ 'address' => $model->getRelation('host')->host, 'port' => $model->getRelation('host')->port, @@ -45,6 +46,7 @@ public function transform(Database $model): array 'username' => $model->username, 'connections_from' => $model->remote, 'max_connections' => $model->max_connections, + 'connection_details' => $model->connection_details, ]; } diff --git a/database/Factories/DatabaseFactory.php b/database/Factories/DatabaseFactory.php index 22c40b36c..0c72dd02f 100644 --- a/database/Factories/DatabaseFactory.php +++ b/database/Factories/DatabaseFactory.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Illuminate\Support\Str; use Pterodactyl\Models\Database; +use Pterodactyl\Models\DatabaseHost; use Illuminate\Database\Eloquent\Factories\Factory; class DatabaseFactory extends Factory @@ -28,6 +29,8 @@ public function definition(): array 'username' => Str::random(10), 'remote' => '%', 'password' => $password ?: encrypt('test123'), + 'type' => DatabaseHost::TYPE_MYSQL, + 'connection_details' => null, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; diff --git a/database/Factories/DatabaseHostFactory.php b/database/Factories/DatabaseHostFactory.php index 11ccc9f01..7a93c4713 100644 --- a/database/Factories/DatabaseHostFactory.php +++ b/database/Factories/DatabaseHostFactory.php @@ -26,6 +26,7 @@ public function definition(): array 'port' => 3306, 'username' => $this->faker->colorName, 'password' => Crypt::encrypt($this->faker->word), + 'type' => DatabaseHost::TYPE_MYSQL, ]; } } diff --git a/database/Seeders/EggSeeder.php b/database/Seeders/EggSeeder.php index 360641298..8dc536154 100644 --- a/database/Seeders/EggSeeder.php +++ b/database/Seeders/EggSeeder.php @@ -20,6 +20,7 @@ class EggSeeder extends Seeder */ public static array $import = [ 'Minecraft', + 'Minecraft Bedrock', 'Hytale', 'Source Engine', 'Voice Servers', @@ -45,9 +46,7 @@ public function run() { foreach (static::$import as $nest) { /* @noinspection PhpParamsInspection */ - $this->parseEggFiles( - Nest::query()->where('author', 'support@pterodactyl.io')->where('name', $nest)->firstOrFail() - ); + $this->parseEggFiles(Nest::query()->where('name', $nest)->firstOrFail()); } } diff --git a/database/Seeders/NestSeeder.php b/database/Seeders/NestSeeder.php index 339118679..ef1f4c599 100644 --- a/database/Seeders/NestSeeder.php +++ b/database/Seeders/NestSeeder.php @@ -3,30 +3,20 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; +use Pterodactyl\Models\Nest; use Pterodactyl\Services\Nests\NestCreationService; -use Pterodactyl\Contracts\Repository\NestRepositoryInterface; class NestSeeder extends Seeder { - /** - * @var NestCreationService - */ - private $creationService; - - /** - * @var NestRepositoryInterface - */ - private $repository; + private const PTERODACTYL_AUTHOR = 'support@pterodactyl.io'; + private const PHOSPHOR_AUTHOR = 'support@phosphor.host'; /** * NestSeeder constructor. */ public function __construct( - NestCreationService $creationService, - NestRepositoryInterface $repository, + private NestCreationService $creationService, ) { - $this->creationService = $creationService; - $this->repository = $repository; } /** @@ -36,16 +26,35 @@ public function __construct( */ public function run() { - $items = $this->repository->findWhere([ - 'author' => 'support@pterodactyl.io', - ])->keyBy('name')->toArray(); - - $this->createMinecraftNest(array_get($items, 'Minecraft')); - $this->createHytaleNest(array_get($items, 'Hytale')); - $this->createSourceEngineNest(array_get($items, 'Source Engine')); - $this->createVoiceServersNest(array_get($items, 'Voice Servers')); - $this->createRustNest(array_get($items, 'Rust')); - $this->createVintageStoryNest(array_get($items, 'Vintage Story')); + $items = Nest::query()->get()->keyBy('name'); + + $this->createMinecraftNest($items->get('Minecraft')); + $this->createMinecraftBedrockNest($items->get('Minecraft Bedrock')); + $this->createHytaleNest($items->get('Hytale')); + $this->createSourceEngineNest($items->get('Source Engine')); + $this->createVoiceServersNest($items->get('Voice Servers')); + $this->createRustNest($items->get('Rust')); + $this->createVintageStoryNest($items->get('Vintage Story')); + } + + private function resolveNestAuthor(string $nestName): string + { + $files = new \DirectoryIterator(database_path('Seeders/eggs/' . kebab_case($nestName))); + + foreach ($files as $file) { + if (!$file->isFile() || !$file->isReadable()) { + continue; + } + + $decoded = json_decode(file_get_contents($file->getRealPath()), true, 512, JSON_THROW_ON_ERROR); + $author = $decoded['author'] ?? null; + + if (is_string($author) && $author !== '') { + return $author; + } + } + + return self::PTERODACTYL_AUTHOR; } /** @@ -53,13 +62,28 @@ public function run() * * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - private function createMinecraftNest(?array $nest = null) + private function createMinecraftNest(?Nest $nest = null) { if (is_null($nest)) { $this->creationService->handle([ 'name' => 'Minecraft', 'description' => 'Minecraft - the classic game from Mojang. With support for Vanilla MC, Spigot, and many others!', - ], 'support@pterodactyl.io'); + ], self::PTERODACTYL_AUTHOR); + } + } + + /** + * Create the Minecraft Bedrock nest to be used later on. + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + */ + private function createMinecraftBedrockNest(?Nest $nest = null) + { + if (is_null($nest)) { + $this->creationService->handle([ + 'name' => 'Minecraft Bedrock', + 'description' => 'The Bedrock edition of Minecraft. Comes with support for Vanilla, Endstone, Waterdog, and many others!', + ], self::PHOSPHOR_AUTHOR); } } @@ -68,13 +92,13 @@ private function createMinecraftNest(?array $nest = null) * * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - private function createHytaleNest(?array $nest = null) + private function createHytaleNest(?Nest $nest = null) { if (is_null($nest)) { $this->creationService->handle([ 'name' => 'Hytale', 'description' => 'Hytale - A new sandbox game from Hypixel Studios.', - ], 'support@pterodactyl.io'); + ], self::PTERODACTYL_AUTHOR); } } @@ -83,13 +107,13 @@ private function createHytaleNest(?array $nest = null) * * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - private function createSourceEngineNest(?array $nest = null) + private function createSourceEngineNest(?Nest $nest = null) { if (is_null($nest)) { $this->creationService->handle([ 'name' => 'Source Engine', 'description' => 'Includes support for most Source Dedicated Server games.', - ], 'support@pterodactyl.io'); + ], self::PTERODACTYL_AUTHOR); } } @@ -98,13 +122,13 @@ private function createSourceEngineNest(?array $nest = null) * * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - private function createVoiceServersNest(?array $nest = null) + private function createVoiceServersNest(?Nest $nest = null) { if (is_null($nest)) { $this->creationService->handle([ 'name' => 'Voice Servers', 'description' => 'Voice servers such as Mumble and Teamspeak 3.', - ], 'support@pterodactyl.io'); + ], self::PTERODACTYL_AUTHOR); } } @@ -113,13 +137,13 @@ private function createVoiceServersNest(?array $nest = null) * * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - private function createRustNest(?array $nest = null) + private function createRustNest(?Nest $nest = null) { if (is_null($nest)) { $this->creationService->handle([ 'name' => 'Rust', 'description' => 'Rust - A game where you must fight to survive.', - ], 'support@pterodactyl.io'); + ], self::PTERODACTYL_AUTHOR); } } @@ -128,14 +152,13 @@ private function createRustNest(?array $nest = null) * * @throws \Pterodactyl\Exceptions\Model\DataValidationException */ - private function createVintageStoryNest(?array $nest = null) + private function createVintageStoryNest(?Nest $nest = null) { if (is_null($nest)) { $this->creationService->handle([ 'name' => 'Vintage Story', 'description' => 'Vintage Story - An uncompromising wilderness survival sandbox game.', - ], 'support@pterodactyl.io'); + ], self::PTERODACTYL_AUTHOR); } } - } diff --git a/database/Seeders/eggs/minecraft-bedrock/egg-endstone.json b/database/Seeders/eggs/minecraft-bedrock/egg-endstone.json new file mode 100644 index 000000000..cba12586e --- /dev/null +++ b/database/Seeders/eggs/minecraft-bedrock/egg-endstone.json @@ -0,0 +1,48 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2026-06-27T00:00:00+00:00", + "name": "Endstone", + "author": "support@phosphor.host", + "description": "Endstone offers a high-performance, easy-to-use plugin API for Minecraft: Bedrock Edition servers, supporting both Python and C++ plugins. It launches the official Bedrock Dedicated Server (BDS) with the Endstone runtime injected via LD_PRELOAD. The BDS version is pinned to the installed Endstone version. Runtime image must stay on Python 3.12 to match the install venv.", + "features": [ + "pid_limit" + ], + "docker_images": { + "Python 3.14": "ghcr.io/parkervcp/yolks:python_3.14", + "Python 3.13": "ghcr.io/parkervcp/yolks:python_3.13", + "Python 3.12": "ghcr.io/parkervcp/yolks:python_3.12", + "Python 3.11": "ghcr.io/parkervcp/yolks:python_3.11", + "Python 3.10": "ghcr.io/parkervcp/yolks:python_3.10" + }, + "file_denylist": [], + "startup": "python -m pip install -U pip && pip install -U \\\"{{ENDSTONE_VERSION}}\\\" && python -m endstone -s . -y", + "config": { + "files": "{\"server.properties\":{\"parser\":\"properties\",\"find\":{\"server-port\":\"{{server.build.default.port}}\",\"server-portv6\":\"{{server.build.default.port}}\",\"server-name\":\"{{server.build.env.SERVERNAME}}\",\"gamemode\":\"{{server.build.env.GAMEMODE}}\",\"difficulty\":\"{{server.build.env.DIFFICULTY}}\",\"allow-cheats\":\"{{server.build.env.CHEATS}}\",\"max-players\":\"{{server.build.env.MAXPLAYERS}}\",\"level-name\":\"{{server.build.env.LEVELNAME}}\"}}}", + "startup": "{\"done\":\"Server started.\"}", + "logs": "{}", + "stop": "stop" + }, + "scripts": { + "installation": { + "script": "#!\\/bin\\/bash\\n# Endstone Installation Script\\n#\\n# Server Files: \\/mnt\\/server\\napt update\\napt install -y curl\\n\\nmkdir -p \\/mnt\\/server\\ncd \\/mnt\\/server\\n\\nif [ ! -f server.properties ]; then\\n echo -e \\\"Downloading server.properties\\\"\\n curl -o server.properties https:\\/\\/raw.githubusercontent.com\\/EndstoneMC\\/pterodactyl\\/main\\/server.properties\\nfi\\n\\nexport HOME=\\/mnt\\/server\\n\\necho -e \\\"Installation complete\\\"\\nexit 0", + "container": "python:3.12-slim-bookworm", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Endstone Version", + "description": "The version of the Endstone pip package to install (e.g. 0.11.4). Leave as \"endstone\" for the newest release. This also determines which Bedrock Dedicated Server version is used.", + "env_variable": "ENDSTONE_VERSION", + "default_value": "endstone", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + } + ] +} diff --git a/database/Seeders/eggs/minecraft-bedrock/egg-levilamina.json b/database/Seeders/eggs/minecraft-bedrock/egg-levilamina.json new file mode 100644 index 000000000..fac2022a0 --- /dev/null +++ b/database/Seeders/eggs/minecraft-bedrock/egg-levilamina.json @@ -0,0 +1,112 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2026-04-22T16:10:30+02:00", + "name": "LeviLamina", + "author": "eggs@purpleflaghosting.com", + "description": "LeviLamina is an unofficial mod loader designed to offer indispensable API support for Minecraft Bedrock Edition. It boasts a comprehensive API, an array of utility interfaces, a robust event system, and comprehensive support for basic interfaces. LeviLamina provides an expansive API, a powerful event system, and a wealth of encapsulated development infrastructure interfaces, forming a solid foundation for augmenting the Minecraft Bedrock Edition with additional gameplay features and functionalities. By leveraging mods, the process of extending Bedrock functionality becomes effortless, with a user-friendly development process and an adaptable approach.", + "features": [], + "docker_images": { + "ghcr.io\/ptero-eggs\/yolks:wine_latest": "ghcr.io\/ptero-eggs\/yolks:wine_latest" + }, + "file_denylist": [], + "startup": "cat | wine bedrock_server_mod.exe", + "config": { + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"server-portv6\": \"{{server.build.default.port}}\",\r\n \"server-name\": \"{{server.build.env.SERVERNAME}}\",\r\n \"gamemode\": \"{{server.build.env.GAMEMODE}}\",\r\n \"difficulty\": \"{{server.build.env.DIFFICULTY}}\",\r\n \"max-players\": \"{{server.build.env.MAXPLAYERS}}\",\r\n \"level-name\": \"{{server.build.env.WORLDNAME}}\"\r\n }\r\n }\r\n}", + "startup": "{\r\n \"done\": \"IPv4 supported\"\r\n}", + "logs": "{}", + "stop": "^C" + }, + "scripts": { + "installation": { + "script": "# Install required packages\r\napt update\r\napt install -y lsb-release xvfb unzip jq\r\n\r\n# Install wine\r\nwget -O \/etc\/apt\/keyrings\/winehq-archive.key https:\/\/dl.winehq.org\/wine-builds\/winehq.key\r\nwget -NP \/etc\/apt\/sources.list.d\/ https:\/\/dl.winehq.org\/wine-builds\/debian\/dists\/$(lsb_release -sc 2>\/dev\/null)\/winehq-$(lsb_release -sc 2>\/dev\/null).sources\r\napt update\r\napt install --no-install-recommends winehq-stable cabextract -y\r\n\r\nexport WINEPREFIX=\/root\/.wine\r\nmkdir -p $WINEPREFIX\r\n\r\n\r\n# Setup display\r\nexport DISPLAY=:0\r\nXvfb :0 -screen 0 1024x768x16 &\r\n\r\n\r\n\r\n\r\n# Install Winetricks\r\necho \"Installing winetricks\"\r\nwget -q -O \/usr\/sbin\/winetricks https:\/\/raw.githubusercontent.com\/Winetricks\/winetricks\/master\/src\/winetricks && chmod +x \/usr\/sbin\/winetricks\r\n\r\necho \"Installing mono\"\r\nwget -q -O $WINEPREFIX\/mono.msi https:\/\/dl.winehq.org\/wine\/wine-mono\/9.1.0\/wine-mono-9.1.0-x86.msi\r\nWINEDLLOVERRIDES=\"mscoree,mshtml=\" wine msiexec \/i $WINEPREFIX\/mono.msi \/qn \/quiet \/norestart \/log $WINEPREFIX\/mono_install.log\r\n\r\n# Install dotnet\r\necho \"Installing dotnet\"\r\nwinetricks -q dotnet10 \r\n\r\n\r\n# Setup tty width so wine console output doesn't prematurely wrap\r\nstty columns 250\r\n\r\n\r\ncd \/mnt\/server || { echo \"Failed to change directory\"; exit 1; }\r\n\r\n# Install lip\r\nLIP_VERSION=\"$(wget -qO- https:\/\/api.github.com\/repos\/futrime\/lip\/releases\/latest | jq -r '.tag_name[1:]')\"\r\nLIP_URL=\"https:\/\/github.com\/futrime\/lip\/releases\/download\/v${LIP_VERSION}\/lip-${LIP_VERSION}-win-x64.zip\"\r\nwget \"$LIP_URL\" -O lip.zip\r\nunzip -o lip.zip lip.exe\r\nrm lip.zip\r\n\r\n\r\nif [ -n \"$VERSION\" ] && [ \"$VERSION\" != \"latest\" ]; then\r\n wine lip.exe install github.com\/LiteLDev\/LeviLamina@\"$VERSION\"\r\nelse\r\n wine lip.exe install github.com\/LiteLDev\/LeviLamina\r\nfi\r\n\r\n\r\necho \"Server Installed\"\r\n\r\nif [ -f \"lip-install.txt\" ]; then\r\n for package in $(cat \"lip-install.txt\" ); do\r\n echo \"Installing ${package} from lip-install.txt\"\r\n wine lip.exe install \"${package}\"\r\n done\r\n echo \"Packages installed\"\r\nfi", + "container": "ghcr.io\/ptero-eggs\/installers:debian", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Wine Debug", + "description": "", + "env_variable": "WINEDEBUG", + "default_value": "-all", + "user_viewable": false, + "user_editable": false, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "Version", + "description": "Version of LeviLamina to install in x.y.z format. Blank or \"latest\" will install the latest release.", + "env_variable": "VERSION", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|string|max:20", + "field_type": "text" + }, + { + "name": "Server Name", + "description": "The name for the server", + "env_variable": "SERVERNAME", + "default_value": "Bedrock Dedicated Server", + "user_viewable": true, + "user_editable": true, + "rules": "required|string", + "field_type": "text" + }, + { + "name": "Gamemode", + "description": "Allowed values: \"survival\", \"creative\", or \"adventure\"", + "env_variable": "GAMEMODE", + "default_value": "survival", + "user_viewable": true, + "user_editable": true, + "rules": "required|string", + "field_type": "text" + }, + { + "name": "Difficulty", + "description": "Allowed values: \"peaceful\", \"easy\", \"normal\", or \"hard\"", + "env_variable": "DIFFICULTY", + "default_value": "easy", + "user_viewable": true, + "user_editable": true, + "rules": "required|string", + "field_type": "text" + }, + { + "name": "Maximum Players", + "description": "Maximum players to join on the server", + "env_variable": "MAXPLAYERS", + "default_value": "10", + "user_viewable": true, + "user_editable": true, + "rules": "required|integer", + "field_type": "text" + }, + { + "name": "World Name", + "description": "World\/Level name to use for the server", + "env_variable": "WORLDNAME", + "default_value": "Bedrock level", + "user_viewable": true, + "user_editable": true, + "rules": "required|string", + "field_type": "text" + }, + { + "name": "Winetricks", + "description": "", + "env_variable": "WINETRICKS_RUN", + "default_value": "vcrun2022", + "user_viewable": false, + "user_editable": false, + "rules": "required|string|max:20", + "field_type": "text" + } + ] +} diff --git a/database/Seeders/eggs/minecraft-bedrock/egg-pocketminemp.json b/database/Seeders/eggs/minecraft-bedrock/egg-pocketminemp.json new file mode 100644 index 000000000..0f9669236 --- /dev/null +++ b/database/Seeders/eggs/minecraft-bedrock/egg-pocketminemp.json @@ -0,0 +1,42 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2025-04-20T21:46:27+00:00", + "name": "PocketmineMP", + "author": "info@swisscrafting.ch", + "description": "Pocketmine Egg\r\nby onekintaro from swisscrafting.ch\r\nwith the nice help from #eggs Channel on Pterodactyl-Discord :)", + "features": [], + "docker_images": { + "ghcr.io\/ptero-eggs\/yolks:debian": "ghcr.io\/ptero-eggs\/yolks:debian" + }, + "file_denylist": [], + "startup": ".\/bin\/php7\/bin\/php .\/PocketMine-MP.phar --no-wizard", + "config": { + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", + "startup": "{\r\n \"done\": \")! \"\r\n}", + "logs": "{}", + "stop": "stop" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/bash\r\n\r\napt-get update\r\napt-get install -y git curl wget jq file tar unzip zip\r\n\r\nmkdir -p \/mnt\/server\/ # Not required. Only here for parkervcp's local test setup\r\n\r\ncd \/mnt\/server || exit 1\r\n\r\nARCH=$([[ \"$(uname -m)\" == \"x86_64\" ]] && printf \"x86_64\" || printf \"arm64\")\r\n\r\n# Shouldn't be possible to be empty, but default to PM5 if it is and convert to uppercase\r\nVERSION=\"${VERSION:-PM5}\"\r\nVERSION=\"${VERSION^^}\"\r\n\r\n# Helper functions\r\n\r\ndownload_php_binary() {\r\n local php_binary_url\r\n if [[ \"$VERSION\" == \"PM5\" ]]; then\r\n php_binary_url=\"https:\/\/github.com\/pmmp\/PHP-Binaries\/releases\/download\/pm5-latest\/PHP-8.2-Linux-${ARCH}-PM5.tar.gz\"\r\n elif [[ \"$VERSION\" == \"PM4\" ]]; then\r\n php_binary_url=\"https:\/\/github.com\/pmmp\/PHP-Binaries\/releases\/download\/pm4-latest\/PHP-8.1-Linux-${ARCH}-PM4.tar.gz\"\r\n else\r\n printf \"Unsupported version: %s\\n\" \"${VERSION}\"\r\n exit 1\r\n fi\r\n\r\n printf \"Downloading PHP binary for %s from %s\\n\" \"$VERSION\" \"$php_binary_url\"\r\n curl --location --progress-bar \"$php_binary_url\" | tar -xzv\r\n}\r\n\r\nset_php_extension_dir() {\r\n printf \"Configuring php.ini\\n\"\r\n EXTENSION_DIR=$(find \"bin\" -name '*debug-zts*')\r\n grep -q '^extension_dir' bin\/php7\/bin\/php.ini && \\\r\n sed -i'bak' \"s{^extension_dir=.*{extension_dir=\\\"$EXTENSION_DIR\\\"{\" bin\/php7\/bin\/php.ini || \\\r\n echo \"extension_dir=\\\"$EXTENSION_DIR\\\"\" >>bin\/php7\/bin\/php.ini\r\n}\r\n\r\ndownload_pmmp() {\r\n DOWNLOAD_LINK=$(curl -sSL https:\/\/update.pmmp.io\/api?channel=\"$API_CHANNEL\" | jq -r '.download_url')\r\n printf \"Downloading %s from %s\\n\" \"$VERSION\" \"${DOWNLOAD_LINK}\"\r\n curl --location --progress-bar \"${DOWNLOAD_LINK}\" --output PocketMine-MP.phar\r\n}\r\n\r\n# We have to convert VERSION into an API channel\r\nif [[ \"${VERSION}\" == \"PM4\" ]]; then\r\n API_CHANNEL=\"4\"\r\n\r\nelif [[ \"${VERSION}\" == \"PM5\" ]]; then\r\n API_CHANNEL=\"stable\"\r\nelse\r\n printf \"Unsupported version: %s\\n\" \"${VERSION}\"\r\n exit 1\r\nfi\r\n\r\nREQUIRED_PHP_VERSION=$(curl -sSL https:\/\/update.pmmp.io\/api?channel=\"$API_CHANNEL\" | jq -r '.php_version')\r\n\r\nif [[ \"${ARCH}\" == \"x86_64\" ]]; then\r\n download_php_binary\r\n\r\n# There are no ARM64 PHP binaries yet, so we have to compile them\r\nelse\r\n apt-get install -y make autoconf automake m4 bzip2 bison g++ cmake pkg-config re2c libtool-bin\r\n \r\n mkdir -p \/mnt\/server\/build_cache\/archives\r\n mkdir -p \/mnt\/server\/build_cache\/compilation\r\n \r\n # Each PHP version has its own compile script, so we have to download the correct one\r\n echo \"Running curl to download compile.sh for PHP version $REQUIRED_PHP_VERSION\"\r\n curl --location --progress-bar --remote-name https:\/\/raw.githubusercontent.com\/pmmp\/PHP-Binaries\/latest\/compile.sh\r\n chmod +x compile.sh\r\n\r\n cat <&1 >\/dev\/null\r\nmv .\/target\/linux-x86\/pnx .\/ 2>&1 >\/dev\/null\r\nrm -fr target 2>&1 >\/dev\/null\r\nrm -fr java 2>&1 >\/dev\/null\r\nchmod +x pnx\r\n.\/pnx jvm install=GraalVM\r\nif [ \"$UPGRADE\" == \"no\" ]; then\r\nif [ \"$VERSION\" == \"latest\" ]; then\r\n.\/pnx server install --latest\r\nelse\r\n.\/pnx server install --dev\r\nfi\r\nelse\r\nrm -fr libs 2>&1 >\/dev\/null\r\nif [ \"$VERSION\" == \"latest\" ]; then\r\n.\/pnx server update --latest\r\nelse\r\n.\/pnx server update --dev\r\nfi\r\nfi\r\nrm -fr tmp-GraalVM.tar.gz 2>&1 >\/dev\/null\r\ncat <\/dev\/null \r\ncp -v permissions.json \/tmp\/config_backup\/ 2>\/dev\/null \r\ncp -v allowlist.json \/tmp\/config_backup\/ 2>\/dev\/null \r\n\r\n\r\n\r\necho -e \"Downloading files from: $DOWNLOAD_URL\"\r\ncurl -L -A \"Mozilla\/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/90.0.$RANDVERSION.212 Safari\/537.36\" -H \"Accept-Language: en\" -o $DOWNLOAD_FILE $DOWNLOAD_URL\r\n\r\necho -e \"Unpacking server files\"\r\nunzip -o $DOWNLOAD_FILE\r\n\r\necho -e \"Cleaning up after installing\"\r\nrm $DOWNLOAD_FILE\r\n\r\necho -e \"Restoring backup config files\"\r\ncp -rfv \/tmp\/config_backup\/* \/mnt\/server\/ 2>\/dev\/null || { echo \"No files to restore\"; }\r\n\r\nchmod +x bedrock_server\r\n\r\necho -e \"Install Completed\"", + "container": "ghcr.io\/ptero-eggs\/installers:debian", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Bedrock Version", + "description": "The version of bedrock. (Ex. 1.7.0.13)\r\n\r\nDefault version is latest.", + "env_variable": "BEDROCK_VERSION", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "ld lib path", + "description": "Dumb reasons to need this", + "env_variable": "LD_LIBRARY_PATH", + "default_value": ".", + "user_viewable": false, + "user_editable": false, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "Server Name", + "description": "The name for the server", + "env_variable": "SERVERNAME", + "default_value": "Bedrock Dedicated Server", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:50", + "field_type": "text" + }, + { + "name": "Gamemode", + "description": "Allowed values: \"survival\", \"creative\", or \"adventure\"", + "env_variable": "GAMEMODE", + "default_value": "survival", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|in:survival,creative,adventure", + "field_type": "text" + }, + { + "name": "Difficulty", + "description": "Allowed values: \"peaceful\", \"easy\", \"normal\", or \"hard\"", + "env_variable": "DIFFICULTY", + "default_value": "easy", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|in:peaceful,easy,normal,hard", + "field_type": "text" + }, + { + "name": "Allow cheats", + "description": "If true then cheats like commands can be used.\r\n\r\nAllowed values: \"true\" or \"false\"", + "env_variable": "CHEATS", + "default_value": "false", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|in:true,false", + "field_type": "text" + } + ] +} diff --git a/database/Seeders/eggs/minecraft-bedrock/egg-waterdogpe.json b/database/Seeders/eggs/minecraft-bedrock/egg-waterdogpe.json new file mode 100644 index 000000000..37674ab7a --- /dev/null +++ b/database/Seeders/eggs/minecraft-bedrock/egg-waterdogpe.json @@ -0,0 +1,61 @@ +{ + "_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2024-04-02T11:21:58+02:00", + "name": "Waterdog PE", + "author": "parker@pterodactyl.io", + "description": "Brand new proxy server for Minecraft: Bedrock Edition", + "features": [ + "eula", + "java_version", + "pid_limit" + ], + "docker_images": { + "Java 25": "ghcr.io\/pterodactyl\/yolks:java_25", + "Java 21": "ghcr.io\/pterodactyl\/yolks:java_21", + "Java 17": "ghcr.io\/pterodactyl\/yolks:java_17", + "Java 16": "ghcr.io\/pterodactyl\/yolks:java_16", + "Java 11": "ghcr.io\/pterodactyl\/yolks:java_11", + "Java 8": "ghcr.io\/pterodactyl\/yolks:java_8" + }, + "file_denylist": [], + "startup": "java -Dterminal.ansi=true -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}", + "config": { + "files": "{}", + "startup": "{\r\n \"done\": \"Started query on \"\r\n}", + "logs": "{}", + "stop": "end" + }, + "scripts": { + "installation": { + "script": "#!\/bin\/bash\r\n\r\n\r\nif [ ! -d \/mnt\/server ]; then\r\n mkdir \/mnt\/server\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\n# Adding '.jar' if it isn't part of the file name\r\nif [[ \"${SERVER_JARFILE}\" == \"*\\.jar\" ]]; then\r\n echo -e \"adding.jar to server file name\"\r\n SERVER_JARFILE=\"${SERVER_JARFILE}.jar\"\r\nfi\r\n\r\nif [ -z \"${WATERDOG_VERSION}\" ] || [ \"${WATERDOG_VERSION}\" == \"latest\" ]; then\r\n echo -e \"downloading latest waterdog pe build\\n\"\r\n curl -sSL -o ${SERVER_JARFILE} https:\/\/jenkins.waterdog.dev\/job\/Waterdog\/job\/WaterdogPE\/job\/release\/lastSuccessfulBuild\/artifact\/target\/Waterdog.jar\r\nelse \r\n echo -e \"downloading waterdog pe build ${WATERDOG_VERSION}\\n\"\r\n curl -sSL -o ${SERVER_JARFILE} https:\/\/jenkins.waterdog.dev\/job\/Waterdog\/job\/WaterdogPE\/job\/release\/${WATERDOG_VERSION}\/artifact\/target\/Waterdog.jar\r\nfi\r\n\r\nif [ ! -f config.yml ]; then\r\n echo -e \"Downloading waterdog pe config.yml\"\r\n curl -o config.yml https:\/\/raw.githubusercontent.com\/ptero-eggs\/game-eggs\/main\/minecraft\/proxy\/bedrock\/waterdog_pe\/config.yml\r\nelse\r\n echo -e \"Waterfall config.yml exists. Will not pull a new file\"\r\nfi\r\n\r\n## install end\r\necho \"-----------------------------------------\"\r\necho \"Installation completed...\"\r\necho \"-----------------------------------------\"", + "container": "ghcr.io\/ptero-eggs\/installers:debian", + "entrypoint": "bash" + } + }, + "variables": [ + { + "name": "Waterdog PE Version", + "description": "The build to pull and install. (Ex. 23), or set to latest\r\n\r\nThis is the build number on https:\/\/jenkins.waterdog.dev\/", + "env_variable": "WATERDOG_VERSION", + "default_value": "latest", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + }, + { + "name": "Server Jar File", + "description": "The jar to run to", + "env_variable": "SERVER_JARFILE", + "default_value": "server.jar", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|max:20", + "field_type": "text" + } + ] +} diff --git a/database/migrations/2026_07_12_120000_add_type_columns_to_database_hosts_and_databases.php b/database/migrations/2026_07_12_120000_add_type_columns_to_database_hosts_and_databases.php new file mode 100644 index 000000000..2506c97da --- /dev/null +++ b/database/migrations/2026_07_12_120000_add_type_columns_to_database_hosts_and_databases.php @@ -0,0 +1,30 @@ +string('type', 32)->default('mysql')->after('password'); + }); + + Schema::table('databases', function (Blueprint $table) { + $table->string('type', 32)->default('mysql')->after('password'); + $table->json('connection_details')->nullable()->after('type'); + }); + } + + public function down(): void + { + Schema::table('databases', function (Blueprint $table) { + $table->dropColumn(['type', 'connection_details']); + }); + + Schema::table('database_hosts', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +}; diff --git a/resources/scripts/api/server/databases/createServerDatabase.ts b/resources/scripts/api/server/databases/createServerDatabase.ts index e8c516da5..dbe56b337 100644 --- a/resources/scripts/api/server/databases/createServerDatabase.ts +++ b/resources/scripts/api/server/databases/createServerDatabase.ts @@ -2,13 +2,14 @@ import http from '@/api/http'; import { rawDataToServerDatabase, type ServerDatabase } from '@/api/server/databases/getServerDatabases'; import { getGlobalDaemonType } from '@/api/server/getServer'; -export default (uuid: string, data: { connectionsFrom: string; databaseName: string }): Promise => { +export default (uuid: string, data: { connectionsFrom: string; databaseName: string; databaseType: string }): Promise => { return new Promise((resolve, reject) => { http.post( `/api/client/servers/${getGlobalDaemonType()}/${uuid}/databases`, { database: data.databaseName, remote: data.connectionsFrom, + database_type: data.databaseType, }, { params: { include: 'password' }, diff --git a/resources/scripts/api/server/databases/getServerDatabases.ts b/resources/scripts/api/server/databases/getServerDatabases.ts index 6533ceafe..3e535e3e5 100644 --- a/resources/scripts/api/server/databases/getServerDatabases.ts +++ b/resources/scripts/api/server/databases/getServerDatabases.ts @@ -3,23 +3,38 @@ import { getGlobalDaemonType } from '@/api/server/getServer'; export interface ServerDatabase { id: string; + type: string; name: string; username: string; connectionString: string; allowConnectionsFrom: string; + connectionDetails?: Record; password?: string; } +export interface AvailableDatabaseType { + value: string; + label: string; + supportsRemoteConnections: boolean; +} + +export interface ServerDatabasesResponse { + databases: ServerDatabase[]; + availableTypes: AvailableDatabaseType[]; +} + export const rawDataToServerDatabase = (data: Record): ServerDatabase => ({ id: data.id, + type: data.type, name: data.name, username: data.username, connectionString: `${data.host.address}:${data.host.port}`, allowConnectionsFrom: data.connections_from, + connectionDetails: (data.connection_details || undefined) as Record | undefined, password: data.relationships.password?.attributes?.password, }); -export default (uuid: string, includePassword = true): Promise => { +export default (uuid: string, includePassword = true): Promise => { const daemonType = getGlobalDaemonType(); return new Promise((resolve, reject) => { @@ -27,11 +42,18 @@ export default (uuid: string, includePassword = true): Promise params: includePassword ? { include: 'password' } : undefined, }) .then((response) => - resolve( - (response.data.data || []).map((item: Record) => + resolve({ + databases: (response.data.data || []).map((item: Record) => rawDataToServerDatabase(item.attributes as Record), ), - ), + availableTypes: (response.data.meta?.available_database_types || []).map( + (type: Record) => ({ + value: type.value as string, + label: type.label as string, + supportsRemoteConnections: Boolean(type.supports_remote_connections), + }), + ), + }), ) .catch(reject); }); diff --git a/resources/scripts/components/server/databases/DatabaseRow.tsx b/resources/scripts/components/server/databases/DatabaseRow.tsx index 6bc4c58ee..76919545c 100644 --- a/resources/scripts/components/server/databases/DatabaseRow.tsx +++ b/resources/scripts/components/server/databases/DatabaseRow.tsx @@ -39,7 +39,20 @@ const DatabaseRow = ({ database }: Props) => { const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase); const removeDatabase = ServerContext.useStoreActions((actions) => actions.databases.removeDatabase); - const jdbcConnectionString = `jdbc:mysql://${database.username}${database.password ? `:${encodeURIComponent(database.password)}` : ''}@${database.connectionString}/${database.name}`; + const connectionUri = (() => { + const password = database.password ? `:${encodeURIComponent(database.password)}` : ''; + + switch (database.type) { + case 'postgresql': + return `postgresql://${database.username}${password}@${database.connectionString}/${database.name}`; + case 'mongodb': + return `mongodb://${database.username}${password}@${database.connectionString}/${database.name}?authSource=${database.name}`; + case 'redis': + return `redis://${database.username}${password}@${database.connectionString}`; + default: + return `mysql://${database.username}${password}@${database.connectionString}/${database.name}`; + } + })(); const schema = object().shape({ confirm: string() @@ -119,18 +132,24 @@ const DatabaseRow = ({ database }: Props) => {
+
+ + +
-
+ {database.type === 'mysql' && ( +
-
+
+ )}
@@ -158,12 +177,20 @@ const DatabaseRow = ({ database }: Props) => {
- +
- - + +
+ {database.type === 'redis' && database.connectionDetails?.key_prefix && ( +
+ + + + +
+ )}
@@ -182,24 +209,38 @@ const DatabaseRow = ({ database }: Props) => {
+
+

Type

+

{database.type}

+

Endpoint

{database.connectionString}

-
+ {database.type === 'mysql' && ( +

From

{database.allowConnectionsFrom}

-
+
+ )}

Username

{database.username}

+ {database.type === 'redis' && database.connectionDetails?.key_prefix && ( +
+

Key Prefix

+ +

{database.connectionDetails.key_prefix}

+
+
+ )}
diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index 3d65f8c24..2b2b18b4f 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -1,13 +1,14 @@ import { Database, Plus } from '@gravity-ui/icons'; -import { Form, Formik, type FormikHelpers } from 'formik'; +import { Field as FormikField, Form, Formik, type FormikHelpers } from 'formik'; import { For } from 'million/react'; import { useEffect, useState } from 'react'; import { object, string } from 'yup'; import { httpErrorToHuman } from '@/api/http'; import createServerDatabase from '@/api/server/databases/createServerDatabase'; -import getServerDatabases from '@/api/server/databases/getServerDatabases'; +import getServerDatabases, { type AvailableDatabaseType } from '@/api/server/databases/getServerDatabases'; import Can from '@/components/elements/Can'; import Field from '@/components/elements/Field'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import Modal from '@/components/elements/Modal'; import { PageListContainer } from '@/components/elements/pages/PageList'; import ServerContentBlock from '@/components/elements/ServerContentBlock'; @@ -22,6 +23,7 @@ import { ServerContext } from '@/state/server'; interface DatabaseValues { databaseName: string; connectionsFrom: string; + databaseType: string; } const databaseSchema = object().shape({ @@ -33,7 +35,8 @@ const databaseSchema = object().shape({ /^[\w\-.]{3,48}$/, 'Database name should only contain alphanumeric characters, underscores, dashes, and/or periods.', ), - connectionsFrom: string().matches(/^[\w\-/.%:]+$/, 'A valid host address must be provided.'), + connectionsFrom: string().matches(/^[\w\-/.%:*]+$/, 'A valid host address must be provided.'), + databaseType: string().required('A database type must be selected.'), }); const DatabasesContainer = () => { @@ -43,16 +46,21 @@ const DatabasesContainer = () => { const { addError, clearFlashes } = useFlash(); const [loading, setLoading] = useState(true); const [createModalVisible, setCreateModalVisible] = useState(false); + const [availableTypes, setAvailableTypes] = useState([]); const databases = useDeepMemoize(ServerContext.useStoreState((state) => state.databases.data)); const setDatabases = ServerContext.useStoreActions((state) => state.databases.setDatabases); const appendDatabase = ServerContext.useStoreActions((actions) => actions.databases.appendDatabase); + const canCreateDatabase = availableTypes.length > 0; const submitDatabase = (values: DatabaseValues, { setSubmitting, resetForm }: FormikHelpers) => { clearFlashes('database:create'); + const normalizedConnectionsFrom = values.connectionsFrom.trim() === '*' ? '%' : values.connectionsFrom.trim(); + createServerDatabase(uuid, { databaseName: values.databaseName, - connectionsFrom: values.connectionsFrom || '%', + connectionsFrom: normalizedConnectionsFrom || '%', + databaseType: values.databaseType, }) .then((database) => { resetForm(); @@ -71,7 +79,10 @@ const DatabasesContainer = () => { clearFlashes('databases'); getServerDatabases(uuid) - .then((databases) => setDatabases(databases)) + .then(({ databases, availableTypes }) => { + setDatabases(databases); + setAvailableTypes(availableTypes); + }) .catch((error) => { console.error(error); addError({ key: 'databases', message: httpErrorToHuman(error) }); @@ -83,7 +94,7 @@ const DatabasesContainer = () => {
- {(databaseLimit === null || (databaseLimit > 0 && databaseLimit !== databases.length)) && ( + {canCreateDatabase && (databaseLimit === null || (databaseLimit > 0 && databaseLimit !== databases.length)) && (
- {({ isSubmitting, resetForm }) => ( - { - resetForm(); - setCreateModalVisible(false); - }} - title='Create new database' - > -
- -
- -
+ {({ isSubmitting, resetForm, values }) => { + const selectedDatabaseType = availableTypes.find((type) => type.value === values.databaseType); + + return ( + { + resetForm(); + setCreateModalVisible(false); + }} + title='Create new database' + > +
+ + -
-
- -
- -
- - )} + + + {availableTypes.map((type) => ( + + ))} + + + {selectedDatabaseType?.supportsRemoteConnections && ( +
+ +
+ )} +
+ +
+ +
+
+ ); + }}
{!databases.length && loading ? ( @@ -179,6 +218,8 @@ const DatabasesContainer = () => {

{databaseLimit === 0 ? 'Databases cannot be created for this server.' + : !canCreateDatabase + ? 'No compatible database hosts are available for this server node.' : 'Your server does not have any databases. Create one to get started.'}

diff --git a/resources/views/admin/databases/index.blade.php b/resources/views/admin/databases/index.blade.php index e86ed7066..eca21df97 100644 --- a/resources/views/admin/databases/index.blade.php +++ b/resources/views/admin/databases/index.blade.php @@ -28,6 +28,7 @@ ID Name + Type Host Port Username @@ -38,6 +39,7 @@ {{ $host->id }} {{ $host->name }} + {{ \Pterodactyl\Models\DatabaseHost::typeLabels()[$host->type] ?? $host->type }} {{ $host->host }} {{ $host->port }} {{ $host->username }} @@ -74,23 +76,32 @@

A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.

+
+ + +

The IP address or FQDN that should be used when attempting to connect to this MySQL host from the panel to add new databases.

+

-

The port that MySQL is running on for this host.

+

The port used by this database host.

-

The username of an account that has enough permissions to create new users and databases on the system.

+

The username of an account that has enough permissions to provision databases or credentials on the system.

@@ -114,7 +125,7 @@
+
+ + +
-

The IP address or FQDN that should be used when attempting to connect to this MySQL host from the panel to add new databases.

+

The IP address or FQDN that should be used when attempting to connect to this database host from the panel.

-

The port that MySQL is running on for this host.

+

The port used by this database host.

@@ -62,7 +70,7 @@
-

The username of an account that has enough permissions to create new users and databases on the system.

+

The username of an account that has enough permissions to provision databases or credentials on this host.

@@ -70,7 +78,7 @@

The password to the account defined. Leave blank to continue using the assigned password.


-

The account defined for this database host must have the WITH GRANT OPTION permission. If the defined account does not have this permission requests to create databases will fail. Do not use the same account details for MySQL that you have defined for this panel.

+

Use a database administration account with enough privileges to create and remove databases or users for the selected engine. Do not reuse the same credentials as the panel database.