Add isolated mesh-only Minecraft API with typed bot controls and mod review queue

This commit is contained in:
heather 2026-10-25 16:26:45 +00:00
commit 3e40846b52
3 changed files with 467 additions and 0 deletions

View file

@ -24,6 +24,7 @@
./nick.nix # Nick timer prototype (systemd, localhost:8787) ./nick.nix # Nick timer prototype (systemd, localhost:8787)
./jupyter.nix # JupyterLab (systemd, localhost:8890, лимиты) ./jupyter.nix # JupyterLab (systemd, localhost:8890, лимиты)
./astrnet.nix # AstrNet (Sol): two WireGuard legs, FRR/BGP, ingress guard ./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 # Heather has 8 GiB RAM and runs several containers. Keep a 4 GiB

View file

@ -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;
};
};
}

View file

@ -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('<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()