From 9a3902ae962fdc0c150527f96ea449eadc2a04a5 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Thu, 9 Jul 2026 14:51:56 +0200 Subject: [PATCH] feat: connect via rcon --- .env.example | 6 + .../Tables/AccessRequestsTable.php | 35 ++++ .../Controllers/AccessRequestsController.php | 38 ++-- app/Http/Requests/AccessRequestsRequest.php | 9 +- app/Models/AccessRequests.php | 6 +- app/Services/AccessRequestApprover.php | 37 ++++ app/Services/MinecraftRconClient.php | 162 ++++++++++++++++++ app/Services/RconAccessGrantor.php | 28 +++ config/rcon.php | 9 + phpunit.xml | 3 +- routes/api.php | 8 +- tests/Feature/AccessRequestApproverTest.php | 98 +++++++++++ tests/Feature/AccessRequestsApiTest.php | 80 +++++++++ 13 files changed, 481 insertions(+), 38 deletions(-) create mode 100644 app/Services/AccessRequestApprover.php create mode 100644 app/Services/MinecraftRconClient.php create mode 100644 app/Services/RconAccessGrantor.php create mode 100644 config/rcon.php create mode 100644 tests/Feature/AccessRequestApproverTest.php create mode 100644 tests/Feature/AccessRequestsApiTest.php diff --git a/.env.example b/.env.example index c0660ea..5458e0f 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,9 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +MC_RCON_HOST=minecraft +MC_RCON_PORT=25575 +MC_RCON_PASSWORD= +MC_RCON_ACCESS_COMMAND="whitelist add {username}" +MC_RCON_TIMEOUT=5 diff --git a/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php b/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php index 01e574d..3e0a971 100644 --- a/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php +++ b/app/Filament/Resources/AccessRequests/Tables/AccessRequestsTable.php @@ -2,12 +2,20 @@ namespace App\Filament\Resources\AccessRequests\Tables; +use App\Enums\AccessRequestStatus; +use App\Models\AccessRequests; +use App\Services\AccessRequestApprover; +use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; +use Filament\Notifications\Notification; +use Filament\Support\Icons\Heroicon; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Support\Facades\Auth; +use Throwable; class AccessRequestsTable { @@ -45,6 +53,33 @@ class AccessRequestsTable // ]) ->recordActions([ + Action::make('accept') + ->label('Accepter') + ->icon(Heroicon::CheckCircle) + ->color('success') + ->requiresConfirmation() + ->modalHeading('Accepter la demande') + ->modalDescription(fn (AccessRequests $record): string => "Le joueur {$record->game_username} sera ajoute via RCON.") + ->visible(fn (AccessRequests $record): bool => $record->status === AccessRequestStatus::PENDING) + ->action(function (AccessRequests $record, AccessRequestApprover $approver): void { + try { + $approver->approve($record, Auth::user()); + + Notification::make() + ->success() + ->title('Demande acceptee') + ->send(); + } catch (Throwable $exception) { + report($exception); + + Notification::make() + ->danger() + ->title('Acceptation impossible') + ->body($exception->getMessage()) + ->persistent() + ->send(); + } + }), ViewAction::make(), EditAction::make(), ]) diff --git a/app/Http/Controllers/AccessRequestsController.php b/app/Http/Controllers/AccessRequestsController.php index 85f4e79..15bf2c3 100644 --- a/app/Http/Controllers/AccessRequestsController.php +++ b/app/Http/Controllers/AccessRequestsController.php @@ -2,37 +2,27 @@ namespace App\Http\Controllers; +use App\Enums\AccessRequestStatus; use App\Http\Requests\AccessRequestsRequest; use App\Models\AccessRequests; +use App\Models\GameServers; +use Illuminate\Http\JsonResponse; class AccessRequestsController extends Controller { - public function index() + public function store(AccessRequestsRequest $request): JsonResponse { - return AccessRequests::all(); - } + $validated = $request->validated(); + $serverId = $validated['server_id'] + ?? GameServers::where('slug', $validated['server_slug'])->valueOrFail('id'); - public function store(AccessRequestsRequest $request) - { - return AccessRequests::create($request->validated()); - } + $accessRequest = AccessRequests::create([ + 'game_username' => $validated['game_username'], + 'message' => $validated['message'] ?? '', + 'server_id' => $serverId, + 'status' => AccessRequestStatus::PENDING, + ]); - public function show(AccessRequests $accessRequests) - { - return $accessRequests; - } - - public function update(AccessRequestsRequest $request, AccessRequests $accessRequests) - { - $accessRequests->update($request->validated()); - - return $accessRequests; - } - - public function destroy(AccessRequests $accessRequests) - { - $accessRequests->delete(); - - return response()->json(); + return response()->json($accessRequest, 201); } } diff --git a/app/Http/Requests/AccessRequestsRequest.php b/app/Http/Requests/AccessRequestsRequest.php index 3448655..0fe6525 100644 --- a/app/Http/Requests/AccessRequestsRequest.php +++ b/app/Http/Requests/AccessRequestsRequest.php @@ -9,11 +9,10 @@ class AccessRequestsRequest extends FormRequest public function rules(): array { return [ - 'status' => ['required'], - 'message' => ['required'], - 'reviewed_by' => ['nullable', 'exists:users'], - 'server_id' => ['required', 'exists:game_servers'], - 'reviewed_at' => ['nullable', 'date'], + 'game_username' => ['required', 'string', 'regex:/^[A-Za-z0-9_]{3,16}$/'], + 'message' => ['nullable', 'string', 'max:255'], + 'server_id' => ['nullable', 'required_without:server_slug', 'ulid', 'exists:game_servers,id'], + 'server_slug' => ['nullable', 'required_without:server_id', 'string', 'exists:game_servers,slug'], ]; } diff --git a/app/Models/AccessRequests.php b/app/Models/AccessRequests.php index a0ca35e..203396a 100644 --- a/app/Models/AccessRequests.php +++ b/app/Models/AccessRequests.php @@ -20,6 +20,8 @@ class AccessRequests extends Model 'game_username', 'message', 'server_id', + 'reviewed_by', + 'reviewed_at', ]; public function reviewedBy(): BelongsTo @@ -36,8 +38,8 @@ class AccessRequests extends Model { return [ 'id' => 'string', - 'reviewed_at' => 'timestamp', - 'status' => AccessRequestStatus::class, + 'reviewed_at' => 'datetime', + 'status' => AccessRequestStatus::class, ]; } } diff --git a/app/Services/AccessRequestApprover.php b/app/Services/AccessRequestApprover.php new file mode 100644 index 0000000..a993198 --- /dev/null +++ b/app/Services/AccessRequestApprover.php @@ -0,0 +1,37 @@ +refresh(); + + if ($accessRequest->status !== AccessRequestStatus::PENDING) { + throw new DomainException('Only pending access requests can be accepted.'); + } + + $output = $this->rconAccessGrantor->grant($accessRequest); + + DB::transaction(function () use ($accessRequest, $reviewer): void { + $accessRequest->forceFill([ + 'status' => AccessRequestStatus::ACCEPTED, + 'reviewed_by' => $reviewer?->getKey(), + 'reviewed_at' => now(), + ])->save(); + }); + + return $output; + } +} diff --git a/app/Services/MinecraftRconClient.php b/app/Services/MinecraftRconClient.php new file mode 100644 index 0000000..ad25a55 --- /dev/null +++ b/app/Services/MinecraftRconClient.php @@ -0,0 +1,162 @@ +connect(); + + try { + $this->authenticate(); + + $requestId = $this->nextRequestId(); + $this->writePacket($requestId, self::COMMAND_PACKET_TYPE, $command); + + $response = $this->readPacket(); + + if ($response['id'] !== $requestId) { + throw new RuntimeException('Unexpected RCON response id.'); + } + + return $response['body']; + } finally { + $this->disconnect(); + } + } + + private function connect(): void + { + $host = config('rcon.host'); + $port = (int) config('rcon.port'); + $timeout = (float) config('rcon.timeout'); + + if (blank($host)) { + throw new RuntimeException('MC_RCON_HOST is not configured.'); + } + + $socket = @stream_socket_client( + "tcp://{$host}:{$port}", + $errorCode, + $errorMessage, + $timeout, + ); + + if ($socket === false) { + throw new RuntimeException("Unable to connect to Minecraft RCON: {$errorMessage} ({$errorCode})."); + } + + $this->socket = $socket; + $seconds = (int) floor($timeout); + $microseconds = (int) (($timeout - $seconds) * 1_000_000); + + stream_set_timeout($this->socket, $seconds, $microseconds); + } + + private function authenticate(): void + { + $password = config('rcon.password'); + + if (blank($password)) { + throw new RuntimeException('MC_RCON_PASSWORD is not configured.'); + } + + $requestId = $this->nextRequestId(); + $this->writePacket($requestId, self::AUTH_PACKET_TYPE, (string) $password); + + do { + $response = $this->readPacket(); + } while ($response['type'] !== self::COMMAND_PACKET_TYPE); + + if ($response['id'] === -1 || $response['id'] !== $requestId) { + throw new RuntimeException('Minecraft RCON authentication failed.'); + } + } + + private function disconnect(): void + { + if (is_resource($this->socket)) { + fclose($this->socket); + } + + $this->socket = null; + } + + private function nextRequestId(): int + { + return $this->requestId++; + } + + private function writePacket(int $requestId, int $type, string $body): void + { + $payload = pack('VV', $requestId, $type).$body."\x00\x00"; + $packet = pack('V', strlen($payload)).$payload; + $bytesWritten = 0; + + while ($bytesWritten < strlen($packet)) { + $written = fwrite($this->socket, substr($packet, $bytesWritten)); + + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write RCON packet.'); + } + + $bytesWritten += $written; + } + } + + /** + * @return array{id: int, type: int, body: string} + */ + private function readPacket(): array + { + $sizeData = $this->readBytes(4); + $size = unpack('V', $sizeData)[1]; + + if ($size < 10) { + throw new RuntimeException('Invalid RCON packet size.'); + } + + $packet = $this->readBytes($size); + $header = unpack('Vid/Vtype', substr($packet, 0, 8)); + $id = $header['id'] >= 0x80000000 + ? $header['id'] - 0x100000000 + : $header['id']; + + return [ + 'id' => $id, + 'type' => $header['type'], + 'body' => substr($packet, 8, -2), + ]; + } + + private function readBytes(int $length): string + { + $data = ''; + + while (strlen($data) < $length) { + $chunk = fread($this->socket, $length - strlen($data)); + + if ($chunk === false || $chunk === '') { + throw new RuntimeException('Unable to read RCON packet.'); + } + + $data .= $chunk; + } + + return $data; + } +} diff --git a/app/Services/RconAccessGrantor.php b/app/Services/RconAccessGrantor.php new file mode 100644 index 0000000..1bed71c --- /dev/null +++ b/app/Services/RconAccessGrantor.php @@ -0,0 +1,28 @@ +rconClient->command($this->commandFor($accessRequest->game_username)); + } + + private function commandFor(string $gameUsername): string + { + $accessCommand = (string) config('rcon.access_command', 'whitelist add {username}'); + + if (! str_contains($accessCommand, '{username}')) { + $accessCommand = trim($accessCommand).' {username}'; + } + + return str_replace('{username}', $gameUsername, $accessCommand); + } +} diff --git a/config/rcon.php b/config/rcon.php new file mode 100644 index 0000000..b5136cc --- /dev/null +++ b/config/rcon.php @@ -0,0 +1,9 @@ + env('MC_RCON_HOST', 'minecraft'), + 'port' => (int) env('MC_RCON_PORT', 25575), + 'password' => env('MC_RCON_PASSWORD'), + 'timeout' => (float) env('MC_RCON_TIMEOUT', 5), + 'access_command' => env('MC_RCON_ACCESS_COMMAND', 'whitelist add {username}'), +]; diff --git a/phpunit.xml b/phpunit.xml index a85936a..e7f0a48 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,7 +23,8 @@ - + + diff --git a/routes/api.php b/routes/api.php index 6f26ffb..b80e50b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,10 +1,6 @@ json([ - 'status' => 'ok' - ]); -}); +Route::post('/access-requests', [AccessRequestsController::class, 'store']); diff --git a/tests/Feature/AccessRequestApproverTest.php b/tests/Feature/AccessRequestApproverTest.php new file mode 100644 index 0000000..a057c9a --- /dev/null +++ b/tests/Feature/AccessRequestApproverTest.php @@ -0,0 +1,98 @@ +set('rcon.access_command', 'whitelist add {username}'); + + $rconClient = new FakeMinecraftRconClient; + $this->app->instance(MinecraftRconClient::class, $rconClient); + $reviewer = User::factory()->create(); + $accessRequest = $this->pendingAccessRequest('LeonPlayer'); + + $output = app(AccessRequestApprover::class)->approve($accessRequest, $reviewer); + + $this->assertSame('Added LeonPlayer to the whitelist', $output); + $this->assertSame(['whitelist add LeonPlayer'], $rconClient->commands); + + $this->assertDatabaseHas('access_requests', [ + 'id' => $accessRequest->id, + 'status' => AccessRequestStatus::ACCEPTED->value, + 'reviewed_by' => $reviewer->id, + ]); + + $this->assertNotNull($accessRequest->fresh()->reviewed_at); + } + + public function test_it_does_not_accept_when_rcon_fails(): void + { + $this->app->instance(MinecraftRconClient::class, new FakeMinecraftRconClient(shouldFail: true)); + $accessRequest = $this->pendingAccessRequest('LeonPlayer'); + + $this->expectException(RuntimeException::class); + + try { + app(AccessRequestApprover::class)->approve($accessRequest); + } finally { + $this->assertDatabaseHas('access_requests', [ + 'id' => $accessRequest->id, + 'status' => AccessRequestStatus::PENDING->value, + 'reviewed_by' => null, + 'reviewed_at' => null, + ]); + } + } + + private function pendingAccessRequest(string $gameUsername): AccessRequests + { + $server = GameServers::create([ + 'name' => 'Survival', + 'slug' => 'survival', + ]); + + return AccessRequests::create([ + 'status' => AccessRequestStatus::PENDING, + 'game_username' => $gameUsername, + 'message' => '', + 'server_id' => $server->id, + ]); + } +} + +class FakeMinecraftRconClient extends MinecraftRconClient +{ + /** + * @var array + */ + public array $commands = []; + + public function __construct( + private readonly bool $shouldFail = false, + ) {} + + public function command(string $command): string + { + $this->commands[] = $command; + + if ($this->shouldFail) { + throw new RuntimeException('rcon failed'); + } + + return 'Added LeonPlayer to the whitelist'; + } +} diff --git a/tests/Feature/AccessRequestsApiTest.php b/tests/Feature/AccessRequestsApiTest.php new file mode 100644 index 0000000..1d8a74e --- /dev/null +++ b/tests/Feature/AccessRequestsApiTest.php @@ -0,0 +1,80 @@ + 'Survival', + 'slug' => 'survival', + ]); + + $response = $this->postJson('/api/access-requests', [ + 'game_username' => 'LeonPlayer', + 'message' => 'Je veux rejoindre le serveur.', + 'server_id' => $server->id, + ]); + + $response + ->assertCreated() + ->assertJsonPath('game_username', 'LeonPlayer') + ->assertJsonPath('message', 'Je veux rejoindre le serveur.') + ->assertJsonPath('server_id', $server->id) + ->assertJsonPath('status', AccessRequestStatus::PENDING->value); + + $this->assertDatabaseHas('access_requests', [ + 'game_username' => 'LeonPlayer', + 'message' => 'Je veux rejoindre le serveur.', + 'server_id' => $server->id, + 'status' => AccessRequestStatus::PENDING->value, + ]); + } + + public function test_api_always_creates_pending_access_requests(): void + { + $server = GameServers::create([ + 'name' => 'Survival', + 'slug' => 'survival', + ]); + + $this->postJson('/api/access-requests', [ + 'game_username' => 'LeonPlayer', + 'server_id' => $server->id, + 'status' => AccessRequestStatus::ACCEPTED->value, + ])->assertCreated(); + + $this->assertDatabaseHas('access_requests', [ + 'game_username' => 'LeonPlayer', + 'server_id' => $server->id, + 'status' => AccessRequestStatus::PENDING->value, + ]); + } + + public function test_access_request_can_be_created_with_a_server_slug(): void + { + $server = GameServers::create([ + 'name' => 'Survival', + 'slug' => 'survival', + ]); + + $this->postJson('/api/access-requests', [ + 'game_username' => 'LeonPlayer', + 'server_slug' => 'survival', + ])->assertCreated(); + + $this->assertDatabaseHas('access_requests', [ + 'game_username' => 'LeonPlayer', + 'server_id' => $server->id, + 'status' => AccessRequestStatus::PENDING->value, + ]); + } +}