mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
225 lines
10 KiB
Python
225 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""On the wire, a Horizon client must look like vanilla 26.3's (its brand aside): a server's anticheat reads
|
|
the order and timing of what the client sends, and a difference from vanilla's client can flag an honest
|
|
player or keep them out. A real Horizon client joins a real vanilla 26.3 dedicated server through
|
|
tools/net_capture.py, which records every packet; each rule below is one thing vanilla's client does,
|
|
checked on that capture.
|
|
|
|
The proxy can hold the server's Login packet back (--login-delay, 1 s by default): a busy server, or a
|
|
Paper server with plugins, takes that long to place a new player, and the client's behaviour while it waits
|
|
is part of the wire.
|
|
|
|
Rules (vanilla's code in brackets):
|
|
- no-play-before-login: nothing in the play state before the server's Login. [Minecraft.tick sends
|
|
ServerboundClientTickEndPacket only while it has a level, made by ClientPacketListener.handleLogin]
|
|
- brand-before-information: the configuration phase opens with the brand, then the client information.
|
|
[ClientHandshakePacketListenerImpl.handleLoginFinished]
|
|
- no-play-information: no ClientInformation in the play state while no option changed. [the configuration
|
|
phase sent it; ClientPacketListener.broadcastClientInformation runs only from Options.broadcastOptions]
|
|
- known-packs-as-vanilla: the client answers the server's known packs with the ones it has, vanilla's core
|
|
pack; the server then leaves that pack's registry data out. [KnownPacksManager.trySelectingPacks]
|
|
- known-data-used: with that data left out, a `/say` still shows as vanilla's chat type formats it
|
|
("[Server] ..."): the client filled the entries from its own copy. [RegistryDataCollector]
|
|
|
|
python3 tools/vanilla_wire_e2e.py
|
|
python3 tools/vanilla_wire_e2e.py --rules no-play-before-login --login-delay 2
|
|
|
|
Every game and server here runs in fresh temporary folders (HORIZON_HOME, HORIZON_LAUNCHER_HOME, XDG_*), with no
|
|
audio and no self-update. Needs .local (the vanilla jars and prepared resources) and a built horizon-client and
|
|
horizon-ctl (--bin DIR, default target/fast).
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import lab # noqa: E402
|
|
import net_capture # noqa: E402
|
|
import test_rust_parity as base # noqa: E402
|
|
from server_pack_e2e import Game # noqa: E402
|
|
from test_dedicated_server_parity import Server, free_port # noqa: E402
|
|
|
|
PLAYER = 'Steve'
|
|
PROPERTIES = {
|
|
'online-mode': 'false', 'white-list': 'false', 'level-seed': '12345', 'level-type': 'minecraft\\:flat',
|
|
'generate-structures': 'false', 'view-distance': '3', 'simulation-distance': '3', 'spawn-protection': '0',
|
|
'pause-when-empty-seconds': '-1', 'gamemode': 'survival',
|
|
}
|
|
|
|
|
|
class Failed(Exception):
|
|
pass
|
|
|
|
|
|
def hold_login(seconds):
|
|
"""Make the proxy hold each clientbound Login packet back for `seconds` before forwarding it."""
|
|
record = net_capture.Session.record
|
|
|
|
def delayed(self, direction, packet_id, payload):
|
|
# Hold, then record: the Login's line in the capture comes when the client receives it, so whatever
|
|
# the client sends while it waits is recorded before it, where rule_no_play_before_login looks.
|
|
if (direction == 'clientbound' and self.state['clientbound'] == 'play'
|
|
and self.name(direction, packet_id) == 'login'):
|
|
time.sleep(seconds)
|
|
record(self, direction, packet_id, payload)
|
|
net_capture.Session.record = delayed
|
|
|
|
|
|
def packets(capture, conn):
|
|
return [e for e in net_capture.load(capture) if e.get('conn') == conn and 'name' in e]
|
|
|
|
|
|
def rule_no_play_before_login(entries):
|
|
"""Nothing serverbound in play before the clientbound Login (a client without a level sends nothing)."""
|
|
early = []
|
|
for e in entries:
|
|
if e['dir'] == 's2c' and e['state'] == 'play' and e['name'] == 'login':
|
|
break
|
|
if e['dir'] == 'c2s' and e['state'] == 'play':
|
|
early.append(e['name'])
|
|
if early:
|
|
counts = {n: early.count(n) for n in dict.fromkeys(early)}
|
|
raise Failed(f'{len(early)} serverbound play packets before the server\'s Login: {counts}')
|
|
return 'no serverbound play packet before Login'
|
|
|
|
|
|
def rule_brand_before_information(entries):
|
|
"""The first two serverbound configuration packets: the brand payload, then the client information."""
|
|
sent = [e for e in entries if e['dir'] == 'c2s' and e['state'] == 'configuration'][:2]
|
|
names = [e['name'] for e in sent]
|
|
if names != ['custom_payload', 'client_information']:
|
|
raise Failed(f'configuration opens with {names}, vanilla sends [custom_payload (brand), client_information]')
|
|
payload = bytes.fromhex(sent[0].get('data', ''))
|
|
channel = payload[1:1 + payload[0]] if payload else b'' # a short string: one length byte
|
|
if channel != b'minecraft:brand':
|
|
raise Failed(f'the first payload is {channel!r}, not minecraft:brand')
|
|
return 'brand, then client_information'
|
|
|
|
|
|
def rule_no_play_information(entries):
|
|
"""No client_information in play: the options never change during the run."""
|
|
sent = [e for e in entries if e['dir'] == 'c2s' and e['state'] == 'play' and e['name'] == 'client_information']
|
|
if sent:
|
|
raise Failed(f'{len(sent)} client_information in play with no option changed (first at {sent[0]["t"]} s)')
|
|
return 'no client_information in play'
|
|
|
|
|
|
def rule_known_packs_as_vanilla(entries):
|
|
"""The serverbound select_known_packs carries the same packs as the server's (vanilla's core pack)."""
|
|
offered = [e for e in entries if e['dir'] == 's2c' and e['name'] == 'select_known_packs']
|
|
answer = [e for e in entries if e['dir'] == 'c2s' and e['name'] == 'select_known_packs']
|
|
if not offered or not answer:
|
|
raise Failed(f'no known packs exchange (server {len(offered)}, client {len(answer)})')
|
|
if answer[0].get('data') != offered[0].get('data'):
|
|
raise Failed(f'the server offered {offered[0].get("data")}, the client answered {answer[0].get("data")}')
|
|
return 'answered with the packs offered (minecraft:core)'
|
|
|
|
|
|
def rule_known_data_used(entries, messages):
|
|
"""The client shows the server's /say as vanilla's say_command chat type formats it."""
|
|
if not any('[Server] wire-check' in line for line in messages):
|
|
raise Failed(f'no "[Server] wire-check" among the client\'s messages: {messages[:5]}')
|
|
return '"[Server] wire-check" shown'
|
|
|
|
|
|
RULES = {
|
|
'no-play-before-login': rule_no_play_before_login,
|
|
'brand-before-information': rule_brand_before_information,
|
|
'no-play-information': rule_no_play_information,
|
|
'known-packs-as-vanilla': rule_known_packs_as_vanilla,
|
|
'known-data-used': rule_known_data_used,
|
|
}
|
|
|
|
|
|
def run(args, out):
|
|
lock, jars, libraries = base.verify_artifacts()
|
|
deps = os.pathsep.join(map(str, [jars['server'], *libraries]))
|
|
vanilla = ['java', '-Xmx1g', '-cp', deps, 'net.minecraft.server.Main', 'nogui']
|
|
resources = lab.LOCAL / 'horizon-client' / lab.VERSION
|
|
work = Path(tempfile.mkdtemp(prefix='hz-wire-'))
|
|
print(f'scratch: {work}')
|
|
for var, sub in (('HORIZON_HOME', 'home'), ('HORIZON_LAUNCHER_HOME', 'launcher'), ('XDG_DATA_HOME', 'data'),
|
|
('XDG_CONFIG_HOME', 'config')):
|
|
(work / 'servers' / sub).mkdir(parents=True)
|
|
os.environ[var] = str(work / 'servers' / sub)
|
|
os.environ.update(lab.game_env(HORIZON_TELEMETRY_URL='off'))
|
|
directory = work / 'server'
|
|
directory.mkdir()
|
|
port = free_port()
|
|
props = dict(PROPERTIES, **{'server-port': str(port), 'server-ip': '127.0.0.1'})
|
|
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()),
|
|
encoding='iso-8859-1')
|
|
(directory / 'eula.txt').write_text('eula=true\n')
|
|
server = Server(vanilla, directory, 'server')
|
|
capture = out / 'capture.jsonl'
|
|
capture.touch()
|
|
game = None
|
|
messages = []
|
|
try:
|
|
server.wait_for(r'Done \(', timeout=300)
|
|
if args.login_delay > 0:
|
|
hold_login(args.login_delay)
|
|
listen = free_port()
|
|
stop = net_capture.start_background(listen, f'127.0.0.1:{port}', capture)
|
|
game = Game(Path(args.bin), resources, work / 'game')
|
|
game('connect', f'127.0.0.1:{listen}', PLAYER)
|
|
game('wait-for', 'loaded', '--timeout', 180)
|
|
game('wait', args.ticks)
|
|
server.console('say wire-check')
|
|
game('wait', 20)
|
|
messages = [str(m) for m in game('messages', 10, ok=False).get('chat', [])]
|
|
game('disconnect', ok=False)
|
|
time.sleep(1)
|
|
stop.set()
|
|
finally:
|
|
if game is not None:
|
|
game.quit()
|
|
if game.log.exists():
|
|
shutil.copy(game.log, out / 'client.log')
|
|
server.stop()
|
|
shutil.copy(server.log_path, out / server.log_path.name)
|
|
entries = packets(capture, 1)
|
|
results = []
|
|
for name in args.rules:
|
|
try:
|
|
rule = RULES[name]
|
|
detail = rule(entries, messages) if rule is rule_known_data_used else rule(entries)
|
|
results.append((name, 'pass', detail))
|
|
except Failed as e:
|
|
results.append((name, 'FAIL', str(e)))
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
return results
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--bin', default=str(lab.cargo_target() / 'fast'), help='horizon-client and horizon-ctl')
|
|
parser.add_argument('--out', default=str(lab.LOCAL / 'vanilla-wire-e2e'))
|
|
parser.add_argument('--rules', default=','.join(RULES), type=lambda s: s.split(','),
|
|
help='comma-separated rule names (default: all)')
|
|
parser.add_argument('--login-delay', type=float, default=1.0,
|
|
help='seconds the proxy holds the server\'s Login back (0: none)')
|
|
parser.add_argument('--ticks', type=int, default=100, help='game ticks to stay after the world loaded')
|
|
args = parser.parse_args()
|
|
unknown = [r for r in args.rules if r not in RULES]
|
|
if unknown:
|
|
parser.error(f'unknown rules {unknown}; known: {", ".join(RULES)}')
|
|
out = Path(args.out)
|
|
shutil.rmtree(out, ignore_errors=True)
|
|
out.mkdir(parents=True)
|
|
results = run(args, out)
|
|
print(f'\nvanilla 26.3 server, login held {args.login_delay} s; capture in {out / "capture.jsonl"}')
|
|
for name, result, detail in results:
|
|
print(f' {result:4} {name}: {detail}')
|
|
if any(r != 'pass' for _, r, _ in results):
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|