feat: connect via rcon
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user