406 lines
19 KiB
Python
406 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Small typed Minecraft gateway. No shell, subprocess, downloads or game-file access."""
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import socket
|
|
import socketserver
|
|
import struct
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
BOTS = frozenset(('Terra', 'Luna', 'astri', 'milk'))
|
|
PLAYERS = BOTS | {'michaotics', 'SolAstrius'}
|
|
DIMENSIONS = {'overworld', 'the_nether', 'the_end'}
|
|
MAX_OUTPUT = 32768
|
|
LOG = logging.getLogger('minecraft-api')
|
|
|
|
class APIError(Exception):
|
|
def __init__(self, status, message):
|
|
self.status, self.message = status, message
|
|
|
|
class Rcon:
|
|
def __init__(self, password):
|
|
self.password = password
|
|
|
|
def run(self, command):
|
|
# Commands originate exclusively from Gateway's fixed templates.
|
|
with socket.create_connection(('10.0.0.9', 25575), timeout=3) as s:
|
|
s.settimeout(3)
|
|
deadline = time.monotonic() + 6
|
|
def exact(n):
|
|
data = b''
|
|
while len(data) < n:
|
|
if time.monotonic() > deadline:
|
|
raise OSError('RCON deadline')
|
|
part = s.recv(n - len(data))
|
|
if not part:
|
|
raise OSError('RCON closed')
|
|
data += part
|
|
return data
|
|
def send(req, kind, text):
|
|
b = struct.pack('<ii', req, kind) + text.encode('utf-8') + b'\0\0'
|
|
s.sendall(struct.pack('<i', len(b)) + b)
|
|
def receive():
|
|
length, = struct.unpack('<i', exact(4))
|
|
if not 10 <= length <= 65536:
|
|
raise OSError('Invalid RCON frame')
|
|
b = exact(length)
|
|
if b[-2:] != b'\0\0':
|
|
raise OSError('Invalid RCON terminator')
|
|
req, kind = struct.unpack('<ii', b[:8])
|
|
return req, kind, b[8:-2]
|
|
send(1, 3, self.password)
|
|
for _ in range(3):
|
|
req, kind, _ = receive()
|
|
if req == -1:
|
|
raise OSError('RCON auth failed')
|
|
if req == 1 and kind == 2:
|
|
break
|
|
else:
|
|
raise OSError('RCON auth acknowledgement missing')
|
|
send(2, 2, command)
|
|
# Minecraft processes RCON commands sequentially. A second safe
|
|
# command gives an unambiguous boundary for multi-packet replies.
|
|
send(3, 2, 'list')
|
|
chunks, size = [], 0
|
|
while True:
|
|
req, kind, data = receive()
|
|
if req == 3:
|
|
break
|
|
if req != 2 or kind != 0:
|
|
raise OSError('Unexpected RCON response')
|
|
size += len(data)
|
|
if size > MAX_OUTPUT:
|
|
raise APIError(502, 'Minecraft response exceeded the size limit')
|
|
chunks.append(data)
|
|
return b''.join(chunks).decode('utf-8', errors='replace')
|
|
|
|
class Gateway:
|
|
def __init__(self, config, state_dir, rcon=None):
|
|
self.token = config['api_token']
|
|
if not isinstance(self.token, str) or len(self.token) < 40:
|
|
raise ValueError('Strong API token required')
|
|
self.world = config['world']
|
|
self.rcon = rcon or Rcon(config['rcon_password'])
|
|
self.state = pathlib.Path(state_dir)
|
|
self.requests_file = self.state / 'mod-requests.jsonl'
|
|
self.lock = threading.Lock()
|
|
self.budget_lock = threading.Lock()
|
|
self.budget, self.updated = 20.0, time.monotonic()
|
|
self.last = {}
|
|
|
|
def authorize(self, authorization, origin):
|
|
# CLI/server clients only. No browser CORS, cookies or query tokens.
|
|
if origin is not None:
|
|
raise APIError(403, 'Browser-origin requests are disabled')
|
|
expected = 'Bearer ' + self.token
|
|
if not isinstance(authorization, str) or not hmac.compare_digest(authorization.encode(), expected.encode()):
|
|
raise APIError(401, 'Bearer token required')
|
|
|
|
def limit(self):
|
|
with self.budget_lock:
|
|
now = time.monotonic()
|
|
self.budget = min(20.0, self.budget + now - self.updated)
|
|
self.updated = now
|
|
if self.budget < 1:
|
|
raise APIError(429, 'Rate limit: 60/minute, burst 20')
|
|
self.budget -= 1
|
|
|
|
def cooldown(self, key, seconds):
|
|
now = time.monotonic()
|
|
if now - self.last.get(key, -math.inf) < seconds:
|
|
raise APIError(429, 'Action cooldown active')
|
|
self.last[key] = now
|
|
|
|
def command(self, text):
|
|
try:
|
|
return self.rcon.run(text)
|
|
except (OSError, ValueError, struct.error):
|
|
# Never return internal exceptions, config, passwords or file paths.
|
|
raise APIError(503, 'Minecraft RCON unavailable') from None
|
|
|
|
@staticmethod
|
|
def exact_keys(obj, required, optional=()):
|
|
if not isinstance(obj, dict) or not set(required) <= obj.keys() or obj.keys() - set(required) - set(optional):
|
|
raise APIError(400, 'Missing or unexpected fields')
|
|
|
|
@staticmethod
|
|
def query_geometry(q, radius=False):
|
|
Gateway.exact_keys(q, ('x', 'y', 'z'), ('dimension', 'radius') if radius else ('dimension',))
|
|
d = q.get('dimension', 'overworld')
|
|
if d not in DIMENSIONS:
|
|
raise APIError(400, 'Unsupported dimension')
|
|
nums = []
|
|
for key, lo, hi in [('x', -30000, 30000), ('y', -64, 319), ('z', -30000, 30000)]:
|
|
value = q[key]
|
|
if not isinstance(value, str) or not re.fullmatch(r'-?[0-9]{1,5}', value):
|
|
raise APIError(400, 'Coordinates must be bounded integers')
|
|
n = int(value)
|
|
if not lo <= n <= hi:
|
|
raise APIError(400, 'Coordinate out of bounds')
|
|
nums.append(n)
|
|
if radius:
|
|
r = q.get('radius', '16')
|
|
if not isinstance(r, str) or not re.fullmatch(r'[0-9]{1,2}', r) or not 1 <= int(r) <= 32:
|
|
raise APIError(400, 'Radius must be 1..32')
|
|
return d, *nums, int(r)
|
|
return d, *nums
|
|
|
|
def dispatch(self, method, path, query, body):
|
|
self.limit()
|
|
if not self.lock.acquire(blocking=False):
|
|
raise APIError(429, 'Another Minecraft request is running')
|
|
try:
|
|
return self._dispatch(method, path, query, body)
|
|
finally:
|
|
self.lock.release()
|
|
|
|
def _dispatch(self, method, path, q, body):
|
|
if method == 'GET':
|
|
if body:
|
|
raise APIError(400, 'GET body is not supported')
|
|
if path not in ('/v1/world/entities', '/v1/world/container') and q:
|
|
raise APIError(400, 'Unexpected query parameters')
|
|
if path == '/v1/capabilities':
|
|
return {'version': 1, 'bots': sorted(BOTS), 'players': sorted(PLAYERS),
|
|
'read': ['status', 'world', 'player_state', 'bot_inventory', 'nearby_nonplayer_entities', 'container_items', 'mod_requests'],
|
|
'write': ['kick_bot', 'teleport_bot_to_online_player', 'save_world', 'request_mod_review'],
|
|
'denied': ['raw_console', 'op', 'auth', 'whitelist', 'block_edits', 'seed_change', 'mod_install', 'restart', 'files', 'shell'],
|
|
'rate_limit': '60 requests/minute; burst20; one Minecraft request at a time',
|
|
'limits': {'entities': 8, 'radius': 32, 'xz': 30000, 'response_bytes_per_command': MAX_OUTPUT},
|
|
'transport': 'HTTP bearer over WireGuard mesh only'}
|
|
if path == '/v1/status' or path == '/v1/players':
|
|
return {'minecraft': self.command('list')}
|
|
if path == '/v1/world':
|
|
return {'configured_world': self.world, 'day': self.command('time query day'),
|
|
'daytime': self.command('time query daytime'), 'difficulty': self.command('difficulty'),
|
|
'raining': self.command('execute if predicate {condition:"minecraft:weather_check",raining:true}'),
|
|
'thundering': self.command('execute if predicate {condition:"minecraft:weather_check",thundering:true}')}
|
|
m = re.fullmatch(r'/v1/players/([A-Za-z0-9_]{1,16})', path)
|
|
if m:
|
|
name = m[1]
|
|
if name not in PLAYERS:
|
|
raise APIError(403, 'Player outside the allowlist')
|
|
return {'name': name, 'fields': {field: self.command(f'data get entity {name} {field}')
|
|
for field in ('Pos', 'Dimension', 'Health', 'foodLevel')}}
|
|
m = re.fullmatch(r'/v1/bots/([A-Za-z0-9_]{1,16})/inventory', path)
|
|
if m:
|
|
if m[1] not in BOTS:
|
|
raise APIError(403, 'Inventory access is restricted to bots')
|
|
return {'bot': m[1], 'snbt_response': self.command(f'data get entity {m[1]} Inventory')}
|
|
if path == '/v1/world/entities':
|
|
d, x, y, z, r = self.query_geometry(q, radius=True)
|
|
self.cooldown('entities', 3)
|
|
cmd = f'execute in minecraft:{d} positioned {x} {y} {z} as @e[type=!minecraft:player,distance=..{r},sort=nearest,limit=8] run data get entity @s'
|
|
return {'dimension': d, 'center': [x, y, z], 'radius': r, 'max_entities': 8,
|
|
'loaded_only': True, 'snbt_response': self.command(cmd)}
|
|
if path == '/v1/world/container':
|
|
d, x, y, z = self.query_geometry(q)
|
|
return {'dimension': d, 'position': [x, y, z], 'loaded_only': True,
|
|
'snbt_response': self.command(f'execute in minecraft:{d} run data get block {x} {y} {z} Items')}
|
|
if path == '/v1/mod-requests':
|
|
return {'requests': self.read_requests(), 'installation': 'Manual owner approval only; nothing is downloaded or installed'}
|
|
raise APIError(404, 'Unknown endpoint')
|
|
if method != 'POST':
|
|
raise APIError(405, 'Only GET and POST are supported')
|
|
if q:
|
|
raise APIError(400, 'POST query parameters are not supported')
|
|
if path == '/v1/save':
|
|
self.exact_keys(body, ())
|
|
self.cooldown('save', 300)
|
|
return {'minecraft': self.command('save-all flush'), 'note': 'Saves the world, does not create a backup archive'}
|
|
if path == '/v1/bots/kick':
|
|
self.exact_keys(body, ('bot',))
|
|
bot = body['bot']
|
|
if not isinstance(bot, str) or bot not in BOTS:
|
|
raise APIError(403, 'Only allowed bots can be kicked')
|
|
self.cooldown('kick:' + bot, 10)
|
|
return {'minecraft': self.command(f'kick {bot} Disconnected through the Minecraft API')}
|
|
if path == '/v1/bots/teleport':
|
|
self.exact_keys(body, ('bot', 'to_player'))
|
|
bot, target = body['bot'], body['to_player']
|
|
if not isinstance(bot, str) or bot not in BOTS:
|
|
raise APIError(403, 'Only allowed bots can be teleported')
|
|
if not isinstance(target, str) or target not in PLAYERS:
|
|
raise APIError(403, 'Target must be an allowlisted online player')
|
|
self.cooldown('teleport:' + bot, 30)
|
|
# Both arguments are literal validated names, never selectors or coordinates.
|
|
return {'minecraft': self.command(f'teleport {bot} {target}')}
|
|
if path == '/v1/mod-requests':
|
|
self.exact_keys(body, ('project', 'version_id'))
|
|
project, version = body['project'], body['version_id']
|
|
if not isinstance(project, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', project):
|
|
raise APIError(400, 'Expected Modrinth project ID or slug, not a URL')
|
|
if not isinstance(version, str) or not re.fullmatch(r'[A-Za-z0-9]{8}', version):
|
|
raise APIError(400, 'Expected exact eight-character Modrinth version ID')
|
|
old = self.read_requests()
|
|
for item in old:
|
|
if item['project'] == project and item['version_id'] == version:
|
|
return item
|
|
if len(old) >= 100:
|
|
raise APIError(409, 'Review queue is full; contact the owner')
|
|
self.cooldown('mod-request', 60)
|
|
item = {'id': len(old) + 1, 'project': project, 'version_id': version,
|
|
'requested_at': int(time.time()), 'status': 'pending_owner_review',
|
|
'downloaded': False, 'installed': False}
|
|
with self.requests_file.open('a', encoding='utf-8') as f:
|
|
f.write(json.dumps(item) + '\n')
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
LOG.info('mod_request id=%s project=%s version=%s', item['id'], project, version)
|
|
return item
|
|
raise APIError(404, 'Unknown endpoint')
|
|
|
|
def read_requests(self):
|
|
if not self.requests_file.exists():
|
|
return []
|
|
return [json.loads(line) for line in self.requests_file.read_text().splitlines()][:100]
|
|
|
|
|
|
def unique_object(pairs):
|
|
result = {}
|
|
for k, v in pairs:
|
|
if k in result:
|
|
raise ValueError('Duplicate JSON field')
|
|
result[k] = v
|
|
return result
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = 'MinecraftGateway/1'
|
|
sys_version = ''
|
|
protocol_version = 'HTTP/1.0'
|
|
|
|
def setup(self):
|
|
super().setup()
|
|
self.connection.settimeout(4)
|
|
|
|
def log_message(self, *args):
|
|
# The standard logger can echo an attacker-controlled request line.
|
|
pass
|
|
|
|
def send_json(self, status, value):
|
|
data = json.dumps(value, ensure_ascii=True, allow_nan=False).encode()
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
self.send_header('Connection', 'close')
|
|
if status == 401:
|
|
self.send_header('WWW-Authenticate', 'Bearer')
|
|
if status == 429:
|
|
self.send_header('Retry-After', '10')
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def handle_api(self):
|
|
started = time.monotonic()
|
|
status, route = 500, 'invalid'
|
|
try:
|
|
g = self.server.gateway
|
|
if len(self.headers.get_all('Authorization', [])) != 1:
|
|
raise APIError(401, 'One Bearer header required')
|
|
g.authorize(self.headers.get('Authorization'), self.headers.get('Origin'))
|
|
if len(self.path) > 512 or '%' in self.path or '\\' in self.path:
|
|
raise APIError(400, 'Invalid request target')
|
|
u = urlsplit(self.path)
|
|
if u.scheme or u.netloc or u.fragment or not u.path.startswith('/v1/'):
|
|
raise APIError(400, 'Expected a relative /v1/ endpoint')
|
|
route = u.path if re.fullmatch(r'/[A-Za-z0-9_/-]{1,100}', u.path) else 'invalid'
|
|
if route == 'invalid':
|
|
raise APIError(400, 'Invalid endpoint path')
|
|
if self.headers.get('Transfer-Encoding'):
|
|
raise APIError(400, 'Chunked requests are unsupported')
|
|
lengths = self.headers.get_all('Content-Length', [])
|
|
if len(lengths) > 1:
|
|
raise APIError(400, 'Multiple Content-Length headers')
|
|
raw_length = lengths[0] if lengths else '0'
|
|
if not re.fullmatch(r'[0-9]{1,5}', raw_length) or int(raw_length) > 2048:
|
|
raise APIError(413, 'Body too large')
|
|
length = int(raw_length)
|
|
if self.command != 'POST' and length:
|
|
raise APIError(400, 'Unexpected body')
|
|
body = {}
|
|
if self.command == 'POST':
|
|
if self.headers.get('Content-Type', '').split(';')[0].strip() != 'application/json':
|
|
raise APIError(415, 'Content-Type must be application/json')
|
|
raw = self.rfile.read(length)
|
|
if len(raw) != length:
|
|
raise APIError(400, 'Incomplete body')
|
|
body = json.loads(raw, object_pairs_hook=unique_object)
|
|
if not isinstance(body, dict):
|
|
raise APIError(400, 'Expected a JSON object')
|
|
pairs = parse_qs(u.query, keep_blank_values=True, strict_parsing=True, max_num_fields=8) if u.query else {}
|
|
if any(len(v) != 1 for v in pairs.values()):
|
|
raise APIError(400, 'Duplicate query parameters')
|
|
q = {k: v[0] for k, v in pairs.items()}
|
|
result = g.dispatch(self.command, u.path, q, body)
|
|
status = 200
|
|
self.send_json(status, result)
|
|
except APIError as e:
|
|
status = e.status
|
|
self.send_json(status, {'error': e.message})
|
|
except (ValueError, UnicodeError, RecursionError):
|
|
status = 400
|
|
self.send_json(status, {'error': 'Malformed input'})
|
|
except (OSError, TimeoutError):
|
|
status = 503
|
|
try:
|
|
self.send_json(status, {'error': 'Request unavailable or timed out'})
|
|
except OSError:
|
|
pass
|
|
except Exception:
|
|
status = 500
|
|
LOG.error('Internal request failure')
|
|
self.send_json(status, {'error': 'Internal error'})
|
|
finally:
|
|
LOG.info('request method=%s route=%s status=%d ms=%d peer=%s',
|
|
self.command, route, status, int((time.monotonic()-started)*1000), self.client_address[0])
|
|
self.close_connection = True
|
|
|
|
do_GET = handle_api
|
|
do_POST = handle_api
|
|
do_PUT = handle_api
|
|
do_DELETE = handle_api
|
|
do_PATCH = handle_api
|
|
do_HEAD = handle_api
|
|
do_OPTIONS = handle_api
|
|
|
|
class Server(socketserver.ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
request_queue_size = 8
|
|
def __init__(self, address, gateway):
|
|
self.gateway = gateway
|
|
self.slots = threading.BoundedSemaphore(8)
|
|
super().__init__(address, Handler)
|
|
|
|
def process_request(self, request, client_address):
|
|
if not self.slots.acquire(blocking=False):
|
|
self.shutdown_request(request)
|
|
return
|
|
try:
|
|
super().process_request(request, client_address)
|
|
except BaseException:
|
|
self.slots.release()
|
|
raise
|
|
|
|
def process_request_thread(self, request, client_address):
|
|
try:
|
|
super().process_request_thread(request, client_address)
|
|
finally:
|
|
self.slots.release()
|
|
|
|
if __name__ == '__main__':
|
|
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
|
credentials = pathlib.Path(os.environ['CREDENTIALS_DIRECTORY']) / 'config.json'
|
|
config = json.loads(credentials.read_text())
|
|
gateway = Gateway(config, os.environ['STATE_DIRECTORY'])
|
|
LOG.info('Starting Minecraft-only gateway on mesh 10.0.0.9:25580')
|
|
Server(('10.0.0.9', 25580), gateway).serve_forever()
|