Files
horizon/tools/server_admin_e2e.py
T
Ludovic MarieandClaude Opus 5.5 c2aa51c15f Merge origin/main into feat/admin-player-position
Resolve the generated docs/api/server-reference.md row: main's text with
the new dimension field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 14:55:35 +01:00

389 lines
21 KiB
Python

#!/usr/bin/env python3
"""horizon-server's admin API on a real server, end to end (docs/api/server.md).
python3 tools/server_admin_e2e.py --bin-dir target/fast
`horizon-server run` supervises Horizon's dedicated server (offline mode, a flat world); two horizon-bots
players join. Through the control API, with the token from .horizon/api.json, the run lists the players, then
ops and deops, whitelists, bans and pardons by name and by address, kicks, and turns the whitelist on and off,
checking each reply (`changed`, the console's lines), vanilla's files, the server's log and `GET players`.
It also checks the Prometheus text, and that a call without the token is refused and a name with a space is
not run; then reads the settings and changes the difficulty live, and backs up, downloads and deletes a backup,
and runs the same moderation from the `horizon-server` CLI.
The API listens on the address and with the token server.toml's [api] fixes, as a moderation bot's would.
An event stream (server-sent events) opened before the bots join must see them join, Kim0's chat (sent as a raw
chat packet) and the chat filter's refusal of the next one (`chat_filtered`, no `chat`), then the console mutes,
lists and unmutes them by hand (`mute`, `tempmute`, `mutelist`, `unmute`), Lou0's death, the kick and the leaves, typed, with the same events polled as JSON. Last,
`horizon-server mcp` bans and pardons, reads events and refuses an unconfirmed stop, and `horizon-server
console` attaches through a pipe, runs two commands and shows what they log.
Everything runs in a fresh temporary folder with fresh HORIZON_HOME and XDG folders and no audio. Logs go
to --out. Build first:
cargo build --profile fast -p horizon-client -p horizon-client-lib --bins -p horizon-bots -p horizon-server
"""
import argparse
import http.client
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import threading
import time
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab # noqa: E402
from server_bench import DONE, Server # noqa: E402
EXE = '.exe' if os.name == 'nt' else ''
IP = '203.0.113.7'
TOKEN = 'the-moderation-bots-token'
class Failed(Exception):
pass
def check(condition, what):
if not condition:
raise Failed(what)
print(f'ok: {what}')
class Api:
def __init__(self, folder):
endpoint = json.loads((folder / '.horizon' / 'api.json').read_text())
self.host, port = endpoint['address'].rsplit(':', 1)
self.port = int(port)
self.token = endpoint['token']
def call(self, method, path, body=None, token=None, raw=False):
conn = http.client.HTTPConnection(self.host, self.port, timeout=60)
headers = {'Authorization': f'Bearer {self.token if token is None else token}',
'Content-Type': 'application/json'}
conn.request(method, path, json.dumps(body) if body is not None else None, headers)
response = conn.getresponse()
data = response.read()
conn.close()
return response.status, data if raw else data.decode()
def __call__(self, method, path, body=None):
status, text = self.call(method, path, body)
if status != 200:
raise Failed(f'{method} {path} {body}: {status} {text}')
return json.loads(text)
CHAT = 'hello from Kim0'
# A slur in leetspeak, for the chat filter to refuse (docs/chat-filter.md).
FILTERED = 'n1gg3r'
def chat_packet(message):
"""ServerboundChatPacket, unsigned: the message, the time, salt 0, no signature, nothing seen, checksum 0."""
text = message.encode()
return (bytes([len(text)]) + text + int(time.time() * 1000).to_bytes(8, 'big') + bytes(8) + bytes([0])
+ bytes([0]) + bytes(3) + bytes([0])).hex()
def bot(bins, port, name, *extra):
return subprocess.Popen([bins / f'horizon-bots{EXE}', '--server', f'127.0.0.1:{port}', '--bots', '1',
'--behaviour', 'stand', '--seconds', '600', '--name', name, *extra],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
class Stream:
"""`GET /api/v1/events` as server-sent events, read on a thread."""
def __init__(self, api, types):
self.events = []
self.conn = http.client.HTTPConnection(api.host, api.port, timeout=600)
self.conn.request('GET', f'/api/v1/events?types={types}',
headers={'Authorization': f'Bearer {api.token}', 'Accept': 'text/event-stream'})
self.response = self.conn.getresponse()
threading.Thread(target=self._read, daemon=True).start()
def _read(self):
kind = None
for raw in self.response:
line = raw.decode().rstrip('\n')
if line.startswith('event: '):
kind = line[7:]
elif line.startswith('data: '):
event = json.loads(line[6:])
if event['type'] != kind:
raise Failed(f'event {kind} carries {event}')
self.events.append(event)
def seen(self, kind, name):
return [e for e in self.events if e['type'] == kind and e.get('name') == name]
def wait_until(what, test, timeout=120):
deadline = time.time() + timeout
while time.time() < deadline:
if test():
return
time.sleep(0.5)
raise Failed(f'not within {timeout} s: {what}')
def listed(folder, file):
path = folder / file
return [e.get('name') or e.get('ip') for e in json.loads(path.read_text())] if path.exists() else []
def online(api):
return sorted(p['name'] for p in api('GET', '/api/v1/players')['online'])
def scenario(bins, folder, server, port):
api = Api(folder)
wait_until('the supervisor says running', lambda: api('GET', '/api/v1/status')['phase'] == 'running')
check(api.call('GET', '/api/v1/players', token='nope')[0] == 401, 'a call without the token is refused')
stream = Stream(api, 'join,leave,kick,chat,chat_filtered,death')
raw = f'60:9:{chat_packet(CHAT)},80:9:{chat_packet(FILTERED)}'
bots = [bot(bins, port, 'Kim', '--raw', raw), bot(bins, port, 'Lou')]
try:
wait_until('Kim0 and Lou0 online', lambda: online(api) == ['Kim0', 'Lou0'])
wait_until('both joins streamed', lambda: stream.seen('join', 'Kim0') and stream.seen('join', 'Lou0'), 30)
check(stream.seen('join', 'Kim0')[0]['address'] == '127.0.0.1', 'the join event has the address')
wait_until("Kim0's chat streamed", lambda: stream.seen('chat', 'Kim0'), 30)
check(stream.seen('chat', 'Kim0')[0]['message'] == CHAT, f'the chat event has the message ({stream.events})')
wait_until("Kim0's slur refused", lambda: stream.seen('chat_filtered', 'Kim0'), 30)
refused = stream.seen('chat_filtered', 'Kim0')[0]
check((refused['place'], refused['hit'], refused['action']) == ('chat', 1, 'refused')
and 'message' not in refused, f'the chat_filtered event, without the text ({refused})')
check(all(e['message'] != FILTERED for e in stream.seen('chat', 'Kim0')), 'the refused message is no chat event')
log = folder / 'logs' / 'chat-filter.log'
check(log.exists() and 'Kim0' in log.read_text() and FILTERED in log.read_text(),
'logs/chat-filter.log keeps the refused text')
server.wait_for(r"\[chat-filter\] Refused Kim0's chat \(hit 1: refused\)", 30)
# An operator's mutes, from the console, kept with the filter's in chat-filter-mutes.json.
def command(line, expect):
out = api('POST', '/api/v1/command', {'line': line})['output']
check(any(expect in o for o in out), f'{line}: "{expect}" ({out})')
command('mute Lou0 spamming', 'Muted Lou0 until unmuted: spamming')
command('tempmute Kim0 2h', 'Muted Kim0 for 2 hours')
command('tempmute Kim0 soon', "soon isn't a duration: 10m, 2h, 1d")
command('mutelist', 'There are 2 mute(s):')
command('mutelist', 'Lou0 was muted by Server until unmuted: spamming')
command('mutelist', 'Kim0 was muted by Server for 2 more hours')
mutes = json.loads((folder / 'chat-filter-mutes.json').read_text())['mutes']
check([(m['name'], m['by'], m['until'] is None) for m in mutes] == [('Kim0', 'Server', False), ('Lou0', 'Server', True)],
f'chat-filter-mutes.json has both mutes ({mutes})')
command('unmute Lou0', 'Unmuted Lou0')
command('unmute Kim0', 'Unmuted Kim0')
command('mutelist', 'There are no mutes')
players = api('GET', '/api/v1/players')
check(players['max_players'] == 20 and players['ops'] == [] and players['bans'] == [],
f'players: 20 slots, no operators, no bans ({players})')
check(all(p['address'] == '127.0.0.1' and p['op_level'] == 0 for p in players['online']),
'online players have their address and no op level')
check(all(p['dimension'] == 'minecraft:overworld' and len(p['position']) == 3
and all(isinstance(c, (int, float)) for c in p['position']) for p in players['online']),
f"online players have a dimension and an x, y, z position ({[(p['name'], p['dimension'], p['position']) for p in players['online']]})")
wait_until('the sampler sees 2 players',
lambda: 'horizon_players_online 2\n' in api.call('GET', '/api/v1/metrics/prometheus')[1])
text = api.call('GET', '/api/v1/metrics/prometheus')[1]
check('horizon_up 1\n' in text and 'horizon_tps ' in text and 'horizon_mspt{stat="p95"}' in text,
'Prometheus text: up, TPS and MSPT')
def act(path, body, changed, line):
reply = api('POST', path, body)
check(reply['changed'] == changed and any(line in o for o in reply['output']),
f'{path} {body}: changed {changed}, "{line}" ({reply})')
act('/api/v1/players/op', {'name': 'Kim0'}, True, 'Made Kim0 a server operator')
check(listed(folder, 'ops.json') == ['Kim0'], 'ops.json has Kim0')
check([p['op_level'] for p in api('GET', '/api/v1/players')['online'] if p['name'] == 'Kim0'] == [4],
'Kim0 shows op level 4')
act('/api/v1/players/op', {'name': 'Kim0'}, False, 'Nothing changed')
act('/api/v1/players/deop', {'name': 'Kim0'}, True, 'Made Kim0 no longer a server operator')
act('/api/v1/whitelist/add', {'name': 'Lou0'}, True, 'Added Lou0 to the whitelist')
check(listed(folder, 'whitelist.json') == ['Lou0'], 'whitelist.json has Lou0')
act('/api/v1/whitelist/remove', {'name': 'Lou0'}, True, 'Removed Lou0 from the whitelist')
act('/api/v1/players/ban-ip', {'ip': IP, 'reason': 'spam bots'}, True, f'Banned IP {IP}: spam bots')
check([b['ip'] for b in api('GET', '/api/v1/players')['ip_bans']] == [IP], 'players lists the IP ban')
act('/api/v1/players/pardon-ip', {'ip': IP}, True, f'Unbanned IP {IP}')
check(listed(folder, 'banned-ips.json') == [], 'banned-ips.json is empty again')
status, text = api.call('POST', '/api/v1/players/ban', {'name': 'Kim0 extra'})
check(status == 400, f'a name with a space is refused ({status} {text})')
act('/api/v1/players/kick', {'name': 'Nobody'}, False, 'No player was found')
act('/api/v1/players/kick', {'name': 'Kim0', 'reason': 'spamming chat'}, True, 'Kicked Kim0: spamming chat')
server.wait_for(r'Kim0 lost connection: spamming chat', 30)
wait_until('the kick and the leave streamed', lambda: stream.seen('kick', 'Kim0') and stream.seen('leave', 'Kim0'), 30)
check(stream.seen('kick', 'Kim0')[0]['reason'] == 'spamming chat' == stream.seen('leave', 'Kim0')[0]['reason'],
'kick and leave events give the reason')
api('POST', '/api/v1/command', {'line': 'kill Lou0'})
wait_until("Lou0's death streamed", lambda: stream.seen('death', 'Lou0'), 30)
check(stream.seen('death', 'Lou0')[0]['message'] == 'Lou0 was killed', f'the death message ({stream.events})')
polled = api('GET', '/api/v1/events?since=0&types=death')['events']
check([(e['type'], e['name']) for e in polled] == [('death', 'Lou0')], f'events polled as JSON ({polled})')
act('/api/v1/players/ban', {'name': 'Lou0', 'reason': 'cheating'}, True, 'Banned Lou0: cheating')
server.wait_for(r'Lou0 lost connection', 30)
bans = api('GET', '/api/v1/players')['bans']
check([(b['name'], b['reason'], b['source']) for b in bans] == [('Lou0', 'cheating', 'Server')],
'players lists Lou0 banned for cheating by Server')
wait_until('no one online', lambda: online(api) == [], 30)
act('/api/v1/players/pardon', {'name': 'Lou0'}, True, 'Unbanned Lou0')
act('/api/v1/whitelist', {'enabled': True}, True, 'Whitelist is now turned on')
check(api('GET', '/api/v1/players')['whitelist']['enabled'] is True, 'players says the whitelist is on')
act('/api/v1/whitelist', {'enabled': False}, True, 'Whitelist is now turned off')
check(listed(folder, 'banned-players.json') == [], 'banned-players.json is empty again')
config = api('GET', '/api/v1/config')['config']
check(config['api']['token'] == '(set)' and config['world']['difficulty'] == 'easy',
'config: the token hidden, the difficulty easy')
outcome = api('PATCH', '/api/v1/config', {'changes': {'world.difficulty': 'hard', 'server.max_players': 30}})
check(outcome['applied'] == ['world.difficulty'] and outcome['restart'] == ['server.max_players'],
f'config: the difficulty applies now, max_players at the next start ({outcome})')
said = api('POST', '/api/v1/command', {'line': 'difficulty'})['output']
check(any('The difficulty is Hard' in line for line in said), f'the game plays on Hard ({said})')
check('difficulty = "hard"' in (folder / 'server.toml').read_text(), 'server.toml says hard')
status, _ = api.call('PATCH', '/api/v1/config', {'changes': {'engine.path': '/bin/sh'}})
check(status == 403, 'the API cannot change the program the server runs')
name = api('POST', '/api/v1/backups', {'name': 'e2e'})['name']
check(name in [b['name'] for b in api('GET', '/api/v1/backups')['backups']], f'backup {name} is listed')
status, data = api.call('GET', f'/api/v1/backups/download?name={name}', raw=True)
check(status == 200 and data == (folder / 'backups' / name).read_bytes() and data[:2] == b'PK',
f'the download is the zip ({len(data)} bytes)')
api('DELETE', f'/api/v1/backups?name={name}')
check(not (folder / 'backups' / name).exists(), 'the backup is deleted')
cli = [bins / f'horizon-server{EXE}', '--dir', folder]
def run(*words):
return subprocess.run([str(w) for w in cli + list(words)], capture_output=True, text=True,
env=lab.game_env(), timeout=120)
out = run('ban', 'Kim0', 'griefing', 'again')
check(out.returncode == 0 and 'Banned Kim0: griefing again' in out.stdout,
f'horizon-server ban ({out.stdout!r} {out.stderr!r})')
out = run('players')
check('Banned: Kim0 (griefing again)' in out.stdout, f'horizon-server players ({out.stdout!r})')
out = run('pardon', 'Kim0')
check(out.returncode == 0 and 'Unbanned Kim0' in out.stdout, f'horizon-server pardon ({out.stdout!r})')
out = run('players', '--json')
check(out.returncode == 0 and json.loads(out.stdout)['bans'] == [], 'horizon-server players --json')
mcp = subprocess.Popen([str(bins / f'horizon-server{EXE}'), '--dir', str(folder), 'mcp'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, env=lab.game_env())
def rpc(i, method, params):
mcp.stdin.write(json.dumps({'jsonrpc': '2.0', 'id': i, 'method': method, 'params': params}) + '\n')
mcp.stdin.flush()
reply = json.loads(mcp.stdout.readline())
check(reply['id'] == i and 'result' in reply, f'mcp {method} answers ({reply})')
return reply['result']
def tool(i, tool_name, **args):
result = rpc(i, 'tools/call', {'name': tool_name, 'arguments': args})
return result['isError'], json.loads(result['content'][0]['text'])
rpc(1, 'initialize', {'protocolVersion': '2025-06-18', 'capabilities': {}, 'clientInfo': {'name': 'e2e'}})
names = [t['name'] for t in rpc(2, 'tools/list', {})['tools']]
check({'players', 'kick', 'ban', 'events', 'set_config', 'restore'} <= set(names), f'mcp tools ({names})')
error, banned = tool(3, 'ban', name='Kim0', reason='an agent decided')
check(not error and banned['changed'] and 'Banned Kim0: an agent decided' in banned['output'],
f'mcp ban ({banned})')
error, _ = tool(4, 'pardon', name='Kim0')
check(not error and listed(folder, 'banned-players.json') == [], 'mcp pardon')
error, deaths = tool(5, 'events', since=0, types='death')
check(not error and [e['name'] for e in deaths['events']] == ['Lou0'], f'mcp events ({deaths})')
error, refused = tool(6, 'stop')
check(error and 'confirm' in refused['error'], f'mcp stop without confirm is refused ({refused})')
mcp.stdin.close()
mcp.wait(timeout=30)
console = subprocess.Popen([str(bins / f'horizon-server{EXE}'), '--dir', str(folder), 'console'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, env=lab.game_env())
console.stdin.write('say hello from the console\nlist\n')
console.stdin.flush()
time.sleep(3) # the lines come back through the event stream
out, _ = console.communicate('', timeout=60)
check(console.returncode == 0 and 'Attached' in out and 'Detached' in out, f'console attaches and detaches ({out!r})')
check('[Server] hello from the console' in out and 'There are 0 of a max of 20 players online' in out,
f'console commands run and their lines show ({out[-600:]!r})')
api('POST', '/api/v1/command', {'line': 'stop'})
wait_until('the supervisor keeps the API after the game stops',
lambda: api('GET', '/api/v1/status')['phase'] == 'stopped')
act('/api/v1/players/op', {'name': 'Kim0'}, True, 'Made Kim0 a server operator')
act('/api/v1/players/deop', {'name': 'Kim0'}, True, 'Made Kim0 no longer a server operator')
api('POST', '/api/v1/start')
wait_until('the stopped game starts again',
lambda: api('GET', '/api/v1/status')['phase'] == 'running')
finally:
for b in bots:
b.kill()
api('POST', '/api/v1/stop')
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--bin-dir', default=str(lab.ROOT / 'target/fast'))
parser.add_argument('--out', default=str(lab.LOCAL / 'server-admin-e2e'))
args = parser.parse_args()
bins, out = Path(args.bin_dir).resolve(), Path(args.out)
shutil.rmtree(out, ignore_errors=True)
out.mkdir(parents=True)
os.environ.update(lab.game_env())
work = Path(tempfile.mkdtemp(prefix='hz-server-admin-'))
folder = work / 'server'
folder.mkdir()
port, api_port = lab.free_port(), lab.free_port()
(folder / 'eula.txt').write_text('eula=true\n')
(folder / 'server.toml').write_text(f'''[server]
port = {port}
online_mode = false
whitelist = false
view_distance = 3
simulation_distance = 3
[properties]
level-type = "minecraft:flat"
generate-structures = false
spawn-protection = 0
[engine]
path = {json.dumps(str(bins / f'horizon-client{EXE}'))}
resources = {json.dumps(str(lab.RESOURCES.resolve()))}
restart_on_crash = false
[backups]
every = "0"
[panel]
bind = ""
[api]
bind = "127.0.0.1:{api_port}"
token = "{TOKEN}"
''')
server = Server([bins / f'horizon-server{EXE}', '--dir', folder, 'run'], folder, out / 'server.log')
try:
server.wait_for(DONE, 300)
endpoint = json.loads((folder / '.horizon' / 'api.json').read_text())
if (endpoint['address'], endpoint['token']) != (f'127.0.0.1:{api_port}', TOKEN):
raise Failed(f'the API is not where [api] says: {endpoint}')
scenario(bins, folder, server, port)
server.process.wait(timeout=120)
except (Failed, RuntimeError, OSError, subprocess.TimeoutExpired) as e:
sys.exit(f'server_admin_e2e failed (logs in {out}): {e}')
finally:
server.stop()
shutil.rmtree(work, ignore_errors=True)
print('server_admin_e2e: the admin API listed, opped, whitelisted, banned, pardoned and kicked as vanilla does, '
'changed settings and backups, and streamed what happened')
if __name__ == '__main__':
main()