feat: connect via rcon

This commit is contained in:
2026-07-09 14:51:56 +02:00
parent d2c7fecd55
commit 9a3902ae96
13 changed files with 481 additions and 38 deletions
@@ -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(),
])
@@ -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);
}
}
+4 -5
View File
@@ -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'],
];
}
+4 -2
View File
@@ -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,
];
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use App\Enums\AccessRequestStatus;
use App\Models\AccessRequests;
use App\Models\User;
use DomainException;
use Illuminate\Support\Facades\DB;
class AccessRequestApprover
{
public function __construct(
private readonly RconAccessGrantor $rconAccessGrantor,
) {}
public function approve(AccessRequests $accessRequest, ?User $reviewer = null): string
{
$accessRequest->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;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace App\Services;
use RuntimeException;
class MinecraftRconClient
{
private const AUTH_PACKET_TYPE = 3;
private const COMMAND_PACKET_TYPE = 2;
/**
* @var resource|null
*/
private $socket = null;
private int $requestId = 1;
public function command(string $command): string
{
$this->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;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Services;
use App\Models\AccessRequests;
class RconAccessGrantor
{
public function __construct(
private readonly MinecraftRconClient $rconClient,
) {}
public function grant(AccessRequests $accessRequest): string
{
return $this->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);
}
}