diff --git a/hosts/heather/default.nix b/hosts/heather/default.nix index 5699f61..47f56b8 100644 --- a/hosts/heather/default.nix +++ b/hosts/heather/default.nix @@ -24,6 +24,7 @@ ./nick.nix # Nick timer prototype (systemd, localhost:8787) ./jupyter.nix # JupyterLab (systemd, localhost:8890, лимиты) ./astrnet.nix # AstrNet (Sol): two WireGuard legs, FRR/BGP, ingress guard + ./minecraft-api.nix # mesh-only typed Minecraft API, isolated DynamicUser ]; # Heather has 8 GiB RAM and runs several containers. Keep a 4 GiB diff --git a/hosts/heather/minecraft-api.nix b/hosts/heather/minecraft-api.nix new file mode 100644 index 0000000..2c2dbc3 --- /dev/null +++ b/hosts/heather/minecraft-api.nix @@ -0,0 +1,60 @@ +# Minecraft gateway only. Secrets stay outside the Nix store. +{ pkgs, ... }: +{ + systemd.tmpfiles.rules = [ + "d /var/lib/minecraft-api-root 0755 root root -" + ]; + systemd.services.minecraft-api = { + description = "Restricted Minecraft-only API for Sol (mesh only)"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" "astrnet-mesh0.service" "docker.service" ]; + wants = [ "network-online.target" ]; + startLimitIntervalSec = 0; + serviceConfig = { + Type = "simple"; + DynamicUser = true; + ExecStart = "${pkgs.python3}/bin/python3 -B ${./minecraft-api/server.py}"; + Restart = "on-failure"; + RestartSec = 10; + LoadCredential = "config.json:/var/lib/minecraft-api-secrets/config.json"; + StateDirectory = "minecraft-api"; + StateDirectoryMode = "0700"; + UMask = "0077"; + # Only the immutable Nix store, unit credentials and its own queue exist + # inside this root. No MC files, home, host /etc or Docker socket. + RootDirectory = "/var/lib/minecraft-api-root"; + MountAPIVFS = true; + BindReadOnlyPaths = [ "/nix/store" ]; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + NoNewPrivileges = true; + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectClock = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" ]; + RestrictAddressFamilies = [ "AF_INET" "AF_UNIX" ]; + IPAddressDeny = "any"; + IPAddressAllow = [ "10.0.0.0/24" "10.0.1.0/24" "10.0.2.0/24" "10.99.0.0/22" ]; + SocketBindDeny = "any"; + SocketBindAllow = "ipv4:tcp:25580"; + MemoryMax = "128M"; + CPUQuota = "25%"; + TasksMax = 20; + LimitNOFILE = 128; + }; + }; +} diff --git a/hosts/heather/minecraft-api/server.py b/hosts/heather/minecraft-api/server.py new file mode 100644 index 0000000..3595f56 --- /dev/null +++ b/hosts/heather/minecraft-api/server.py @@ -0,0 +1,406 @@ +#!/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()