295 lines
8.5 KiB
PHP
295 lines
8.5 KiB
PHP
|
<?php
|
||
|
// this is the source for the script that RemoteV2 interacts with
|
||
|
|
||
|
define('SRV_REQ_SEC', 'secret key goes here');
|
||
|
define('SRV_REQ_LIFE', 60);
|
||
|
define('SRV_DIR_FMT', '/srv/minecraft/%s');
|
||
|
|
||
|
function base64url_encode(string $input): string {
|
||
|
return rtrim(strtr(base64_encode($input), '+/', '-_'), '=');
|
||
|
}
|
||
|
|
||
|
function base64url_decode(string $input): string {
|
||
|
return base64_decode(str_pad(strtr($input, '-_', '+/'), strlen($input) % 4, '=', STR_PAD_RIGHT));
|
||
|
}
|
||
|
|
||
|
function rcon_open(int $port) {
|
||
|
$conn = fsockopen('localhost', $port, $code, $message, 2);
|
||
|
return $conn;
|
||
|
}
|
||
|
|
||
|
function rcon_close($conn) {
|
||
|
if(is_resource($conn))
|
||
|
fclose($conn);
|
||
|
}
|
||
|
|
||
|
function rcon_recv($conn): array {
|
||
|
if(!is_resource($conn))
|
||
|
return ['error' => ':rcon:conn'];
|
||
|
|
||
|
extract(unpack('Vlength', fread($conn, 4)));
|
||
|
if($length < 10)
|
||
|
return ['error' => ':rcon:length'];
|
||
|
|
||
|
extract(unpack('VreqId/Vopcode', fread($conn, 8)));
|
||
|
$body = substr(fread($conn, $length - 8), 0, -2);
|
||
|
|
||
|
return compact('reqId', 'opcode', 'body');
|
||
|
}
|
||
|
|
||
|
function rcon_send($conn, int $opcode, string $text): int {
|
||
|
if(!is_resource($conn))
|
||
|
return -1;
|
||
|
$length = 10 + strlen($text);
|
||
|
$reqId = random_int(1, 0x7FFFFFFF);
|
||
|
fwrite($conn, pack('VVV', $length, $reqId, $opcode) . $text . "\0\0");
|
||
|
return $reqId;
|
||
|
}
|
||
|
|
||
|
function rcon_send_large($conn, int $opcode, string $text): array {
|
||
|
if(!is_resource($conn))
|
||
|
return ['error' => ':rcon:conn'];
|
||
|
|
||
|
$reqId = rcon_send($conn, $opcode, $text);
|
||
|
if($reqId < 1) return ['error' => ':rcon:request'];
|
||
|
|
||
|
$trailer = rcon_send($conn, 2, 'time query gametime');
|
||
|
if($trailer < 1) return ['error' => ':rcon:trailer'];
|
||
|
|
||
|
$opcode = 0;
|
||
|
$body = '';
|
||
|
|
||
|
for(;;) {
|
||
|
$resp = rcon_recv($conn);
|
||
|
if(!empty($resp['error']))
|
||
|
return $resp;
|
||
|
|
||
|
if($resp['reqId'] === $trailer) break;
|
||
|
if($resp['reqId'] !== $reqId) continue;
|
||
|
|
||
|
if($resp['opcode'] !== 0)
|
||
|
return ['error' => ':rcon:opcode'];
|
||
|
|
||
|
$body .= $resp['body'];
|
||
|
}
|
||
|
|
||
|
return compact('reqId', 'trailer', 'opcode', 'body');
|
||
|
}
|
||
|
|
||
|
function rcon_open_props(array $props) {
|
||
|
if(empty($props['rcon.port']))
|
||
|
die('{"error":":conf:rcon-port"}');
|
||
|
if(empty($props['rcon.password']))
|
||
|
die('{"error":":conf:rcon-passwd"}');
|
||
|
|
||
|
return rcon_open($props['rcon.port']);
|
||
|
}
|
||
|
|
||
|
function rcon_auth_props($conn, array $props): void {
|
||
|
if(empty($props['rcon.password']))
|
||
|
die('{"error":":conf:rcon-passwd"}');
|
||
|
|
||
|
rcon_send($conn, 3, $props['rcon.password']);
|
||
|
$resp = rcon_recv($conn);
|
||
|
if(!empty($resp['error']))
|
||
|
die(json_encode(['error' => $resp['error']]));
|
||
|
}
|
||
|
|
||
|
function rcon_get_whitelist($conn): array {
|
||
|
$resp = rcon_send_large($conn, 2, 'whitelist list');
|
||
|
if(!empty($resp['error'])) return $resp;
|
||
|
|
||
|
$halfs = explode(':', $resp['body'], 2);
|
||
|
if(empty($halfs[1])) return [];
|
||
|
|
||
|
$names = explode(',', $halfs[1]);
|
||
|
foreach($names as &$name)
|
||
|
$name = trim($name);
|
||
|
|
||
|
return array_values(array_filter($names));
|
||
|
}
|
||
|
|
||
|
function rcon_add_whitelist($conn, array $userNames): array {
|
||
|
$results = [];
|
||
|
|
||
|
foreach($userNames as $name) {
|
||
|
rcon_send($conn, 2, 'whitelist add ' . $name); // todo: sanitise username
|
||
|
$resp = rcon_recv($conn);
|
||
|
|
||
|
$results[$name] = [
|
||
|
'success' => str_starts_with($resp['body'], 'Added '),
|
||
|
'message' => $resp['body'],
|
||
|
];
|
||
|
}
|
||
|
|
||
|
rcon_send($conn, 2, 'whitelist reload');
|
||
|
rcon_recv($conn); // discard reload message
|
||
|
|
||
|
return compact('results');
|
||
|
}
|
||
|
|
||
|
function rcon_remove_whitelist($conn, array $userNames): array {
|
||
|
$results = [];
|
||
|
|
||
|
foreach($userNames as $name) {
|
||
|
rcon_send($conn, 2, 'whitelist remove ' . $name); // todo: sanitise username
|
||
|
$resp = rcon_recv($conn);
|
||
|
|
||
|
$results[$name] = [
|
||
|
'success' => str_starts_with($resp['body'], 'Removed '),
|
||
|
'message' => $resp['body'],
|
||
|
];
|
||
|
}
|
||
|
|
||
|
rcon_send($conn, 2, 'whitelist reload');
|
||
|
rcon_recv($conn); // discard reload message
|
||
|
|
||
|
return compact('results');
|
||
|
}
|
||
|
|
||
|
function read_server_props(string $serverPath, string $fileName = 'server.properties'): array {
|
||
|
$props = [];
|
||
|
|
||
|
$path = realpath($serverPath . '/' . $fileName);
|
||
|
if(is_file($path)) {
|
||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
|
foreach($lines as $line) {
|
||
|
if(empty($line) || str_starts_with($line, '#'))
|
||
|
continue;
|
||
|
|
||
|
$parts = explode('=', $line, 2);
|
||
|
if(count($parts) == 2) {
|
||
|
$value = $parts[1];
|
||
|
if($value === 'false' || $value === 'true')
|
||
|
$value = $value === 'true';
|
||
|
elseif(ctype_digit($value))
|
||
|
$value = (int)$value;
|
||
|
elseif(is_numeric($value))
|
||
|
$value = (float)$value;
|
||
|
|
||
|
$props[$parts[0]] = $value;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $props;
|
||
|
}
|
||
|
|
||
|
function create_request_hash(int $time = -1, ?array $params = null, ?string $method = null, ?string $path = null): string {
|
||
|
if($time < 0)
|
||
|
$time = time();
|
||
|
if($params === null)
|
||
|
$params = $_REQUEST ?? [];
|
||
|
if($method === null)
|
||
|
$method = $GLOBALS['reqMethod'] ?? '';
|
||
|
if($path === null)
|
||
|
$path = $GLOBALS['reqPath'] ?? '';
|
||
|
|
||
|
ksort($params);
|
||
|
$compare = [];
|
||
|
|
||
|
$stringify = null; // use() gets sad if it's not defined yet
|
||
|
$stringify = function(array $arr, string $prefix = '') use(&$compare, &$stringify) {
|
||
|
foreach($arr as $name => $value) {
|
||
|
if(is_array($value))
|
||
|
$stringify($value, $name . ';');
|
||
|
else
|
||
|
$compare[] = "{$name}:{$value}";
|
||
|
}
|
||
|
};
|
||
|
|
||
|
$stringify($params);
|
||
|
$input = "{$time}%{$method} {$path}%" . implode('#', $compare);
|
||
|
|
||
|
return hash_hmac('sha256', $input, SRV_REQ_SEC, true);
|
||
|
}
|
||
|
|
||
|
function verify_request_hash(): bool {
|
||
|
$realTime = time();
|
||
|
$lifeTimeHalf = (int)ceil(SRV_REQ_LIFE / 2);
|
||
|
|
||
|
$userHash = base64url_decode((string)filter_input(INPUT_SERVER, 'HTTP_X_MINCE_SIGNATURE'));
|
||
|
$userTime = (int)filter_input(INPUT_SERVER, 'HTTP_X_MINCE_TIMESTAMP', FILTER_SANITIZE_NUMBER_INT);
|
||
|
if(strlen($userHash) !== 32 || $userTime < ($realTime - $lifeTimeHalf) || $userTime > ($realTime + $lifeTimeHalf))
|
||
|
return false;
|
||
|
|
||
|
return hash_equals(create_request_hash($userTime), $userHash);
|
||
|
}
|
||
|
|
||
|
function get_server_path(string $serverId): string {
|
||
|
if(empty($serverId) || !ctype_alnum($serverId))
|
||
|
die('{"error":":req:id"}');
|
||
|
|
||
|
$path = sprintf(SRV_DIR_FMT, $serverId);
|
||
|
if(!is_dir($path))
|
||
|
die('{"error":":req:server"}');
|
||
|
|
||
|
return $path;
|
||
|
}
|
||
|
|
||
|
$reqMethod = (string)filter_input(INPUT_SERVER, 'REQUEST_METHOD');
|
||
|
$reqPath = '/' . trim(parse_url((string)filter_input(INPUT_SERVER, 'REQUEST_URI'), PHP_URL_PATH), '/');
|
||
|
|
||
|
header('Content-Type: application/json; charset=utf-8');
|
||
|
|
||
|
if(!verify_request_hash())
|
||
|
die('{"error":":request:verification"}');
|
||
|
|
||
|
if($reqMethod === 'GET' && $reqPath === '/') {
|
||
|
$dirs = glob(__DIR__ . '/../*');
|
||
|
$servers = [];
|
||
|
|
||
|
foreach($dirs as $dir)
|
||
|
if(is_file($dir . '/server.properties') && !is_file($dir . '/.dead'))
|
||
|
$servers[] = basename($dir);
|
||
|
|
||
|
echo json_encode(compact('servers'));
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if($reqMethod === 'GET' && $reqPath === '/whitelist') {
|
||
|
$sPath = get_server_path((string)filter_input(INPUT_GET, 'server'));
|
||
|
$sProps = read_server_props($sPath);
|
||
|
$rcon = rcon_open_props($sProps);
|
||
|
|
||
|
try {
|
||
|
rcon_auth_props($rcon, $sProps);
|
||
|
echo json_encode(['list' => rcon_get_whitelist($rcon)]);
|
||
|
} finally {
|
||
|
rcon_close($rcon);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if($reqMethod === 'POST' && $reqPath === '/whitelist') {
|
||
|
$wNames = json_decode((string)filter_input(INPUT_POST, 'names'));
|
||
|
$sPath = get_server_path((string)filter_input(INPUT_POST, 'server'));
|
||
|
$sProps = read_server_props($sPath);
|
||
|
$rcon = rcon_open_props($sProps);
|
||
|
|
||
|
try {
|
||
|
rcon_auth_props($rcon, $sProps);
|
||
|
echo json_encode(rcon_add_whitelist($rcon, $wNames));
|
||
|
} finally {
|
||
|
rcon_close($rcon);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if($reqMethod === 'DELETE' && $reqPath === '/whitelist') {
|
||
|
$wNames = json_decode((string)filter_input(INPUT_GET, 'names'));
|
||
|
$sPath = get_server_path((string)filter_input(INPUT_GET, 'server'));
|
||
|
$sProps = read_server_props($sPath);
|
||
|
$rcon = rcon_open_props($sProps);
|
||
|
|
||
|
try {
|
||
|
rcon_auth_props($rcon, $sProps);
|
||
|
echo json_encode(rcon_remove_whitelist($rcon, $wNames));
|
||
|
} finally {
|
||
|
rcon_close($rcon);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
echo '{"error":":request:notfound"}';
|