mince/private/remote.php

295 lines
8.5 KiB
PHP
Raw Normal View History

<?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"}';