#!/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(' 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()