#!/usr/bin/env python3 """Offline stand-in for the RustexRemake server: accepts any nickname and token and drops the client into an empty world, so the modloader can be verified end to end without the live server. Protocol: Minecraft 1.12.2 (340), no compression, no encryption. Run: python server.py [--port 25566] [--host 0.0.0.0] """ import argparse import hashlib import select import socket import struct import sys import threading import time import uuid as uuidlib PROTOCOL = 340 GAMEMODE_SURVIVAL = 0 DIMENSION_OVERWORLD = 0 DIFFICULTY_NORMAL = 1 WORLD_NAME = "flat" CLIENTBOUND_PLAY = { 0x00: "KEEP_ALIVE", 0x01: "JOIN_GAME", 0x06: "SPAWN_POSITION", 0x08: "PLAYER_POS_LOOK", 0x23: "PLUGIN_MESSAGE", } SERVERBOUND_PLAY = { 0x00: "KEEP_ALIVE", 0x01: "CHAT", 0x02: "CLIENT_STATUS", 0x03: "CLIENT_SETTINGS", 0x04: "PLUGIN_MESSAGE", 0x06: "POSITION", 0x07: "POSITION_LOOK", 0x1A: "TELEPORT_CONFIRM", } class ProtocolError(Exception): pass class Conn: def __init__(self, sock, peer): self.sock = sock self.peer = peer self.buf = b"" def close(self): try: self.sock.close() except OSError: pass def _fill(self, n): while len(self.buf) < n: chunk = self.sock.recv(65536) if not chunk: raise ProtocolError("closed by peer") self.buf += chunk def read_exact(self, n): self._fill(n) out, self.buf = self.buf[:n], self.buf[n:] return out def read_varint(self): value = 0 for shift in range(0, 35, 7): byte = self.read_exact(1)[0] value |= (byte & 0x7F) << shift if not byte & 0x80: return value raise ProtocolError("varint too long") def read_string(self): return self.read_exact(self.read_varint()).decode("utf-8", "replace") def read_ushort(self): return struct.unpack(">H", self.read_exact(2))[0] def read_packet(self): length = self.read_varint() if length <= 0 or length > 2_000_000: raise ProtocolError("bad packet length %d" % length) pid = self.read_varint() payload = self.read_exact(length - varint_size(pid)) return pid, payload def send(self, pid, payload=b""): body = varint(pid) + payload self.sock.sendall(varint(len(body)) + body) def varint(value): out = b"" while True: byte = value & 0x7F value >>= 7 out += bytes([byte | (0x80 if value else 0)]) if not value: return out def varint_size(value): size = 1 while value >= 0x80: value >>= 7 size += 1 return size def mc_string(text): raw = text.encode("utf-8") return varint(len(raw)) + raw def mc_bool(value): return b"\x01" if value else b"\x00" def offline_uuid(name): return uuidlib.uuid3(uuidlib.NAMESPACE_DNS, "OfflinePlayer." + name) def log(name, fmt, *args): stamp = time.strftime("%H:%M:%S") sys.stdout.write("[%s] %-12s %s\n" % (stamp, name, fmt % args if args else fmt)) sys.stdout.flush() def send_status(conn): payload = ( mc_string(json_status()) ) conn.send(0x00, payload) def json_status(): return ( '{"version":{"name":"RustexRemake","protocol":%d},' '"players":{"max":20,"online":1},' '"description":{"text":"rsx modloader test server"}}' % PROTOCOL ) def send_keep_alive(conn, ident): conn.send(0x00, varint(ident & 0x7FFFFFFF)) def send_join_game(conn, entity_id): payload = b"".join([ struct.pack(">i", entity_id), bytes([GAMEMODE_SURVIVAL]), struct.pack(">i", DIMENSION_OVERWORLD), bytes([DIFFICULTY_NORMAL]), bytes([20]), mc_string(WORLD_NAME), mc_bool(False), ]) conn.send(0x01, payload) def send_spawn_position(conn, x=0.5, y=64.0, z=0.5): payload = struct.pack(">ddd", x, y, z) conn.send(0x06, payload) def send_pos_look(conn, x, y, z, yaw, pitch, teleport_id): payload = b"".join([ struct.pack(">ddd", x, y, z), struct.pack(">ff", yaw, pitch), bytes([0]), varint(teleport_id), ]) conn.send(0x08, payload) def send_brand(conn, brand): inner = mc_string(brand) conn.send(0x23, mc_string("MC|Brand") + varint(len(inner)) + inner) def handle(conn): name = None entity_id = None teleport_id = 0 keepalive_id = 0x1234 last_keepalive = 0.0 try: pid, payload = conn.read_packet() if pid != 0x00: raise ProtocolError("expected handshake, got %d" % pid) cursor = Cursor(payload) protocol = cursor.varint() host = cursor.string() port = cursor.ushort() next_state = cursor.varint() log("handshake", "protocol=%d host=%s port=%d next_state=%d", protocol, host, port, next_state) if next_state == 1: pid, _ = conn.read_packet() send_status(conn) log("status", "ping answered") return if next_state != 2: raise ProtocolError("unsupported next_state %d" % next_state) pid, payload = conn.read_packet() if pid != 0x00: raise ProtocolError("expected login start, got %d" % pid) cursor = Cursor(payload) name = cursor.string() profile_id = None if cursor.remaining() > 0: try: profile_id = cursor.string() except ProtocolError: pass if name is None: raise ProtocolError("no nickname") profile_id = profile_id or str(offline_uuid(name)).replace("-", "") log("login", "nick=%r token-ignored uuid=%s", name, profile_id) conn.send(0x02, mc_string(profile_id) + mc_string(name)) log("login", "login success sent, any token accepted") deadline = time.time() + 20 while time.time() < deadline: pid, payload = conn.read_packet() if pid == 0x03: log("login", "client acknowledged") break if pid == 0x00: continue log("login", "unexpected packet 0x%02X before play" % pid) else: raise ProtocolError("no login acknowledgement") entity_id = int.from_bytes(hashlib.sha256(name.encode()).digest()[:4], "big") & 0x7FFFFFFF send_brand(conn, "rsx-testserver") send_join_game(conn, entity_id) send_pos_look(conn, 0.5, 64.0, 0.5, 0.0, 0.0, teleport_id) log("play", "join game sent, entity=%d, spawn sent" % entity_id) while True: if time.time() - last_keepalive > 15: last_keepalive = time.time() keepalive_id = (keepalive_id + 1) & 0x7FFFFFFF send_keep_alive(conn, keepalive_id) pid, payload = conn.read_packet() label = SERVERBOUND_PLAY.get(pid, "0x%02X" % pid) if pid == 0x00: send_keep_alive(conn, keepalive_id) log("play", "keep alive") elif pid == 0x1A: log("play", "teleport confirmed") teleport_id += 1 send_pos_look(conn, 0.5, 64.0, 0.5, 0.0, 0.0, teleport_id) log("play", "position sent after teleport confirm") elif pid == 0x02: log("play", "client status (respawn?): %s", payload.hex()) elif pid == 0x07: log("play", "player position+look received") else: log("play", "%s (%d bytes)", label, len(payload)) except ProtocolError as exc: log("close", "%s: %s", conn.peer, exc) except OSError as exc: log("close", "%s: %s", conn.peer, exc) except Exception as exc: # noqa: BLE001 - stand-in server, log and move on log("error", "%s: %r", conn.peer, exc) finally: if name: log("bye", "nick=%r entity=%s", name, entity_id) conn.close() class Cursor: def __init__(self, data): self.data = data self.pos = 0 def remaining(self): return len(self.data) - self.pos def take(self, n): if self.pos + n > len(self.data): raise ProtocolError("packet truncated") out = self.data[self.pos:self.pos + n] self.pos += n return out def byte(self): return self.take(1)[0] def ubyte(self): return self.byte() def ushort(self): return struct.unpack(">H", self.take(2))[0] def varint(self): value = 0 for shift in range(0, 35, 7): byte = self.byte() value |= (byte & 0x7F) << shift if not byte & 0x80: return value raise ProtocolError("varint too long") def string(self): return self.take(self.varint()).decode("utf-8", "replace") def parse_ports(spec): ports = [] for chunk in spec.split(","): chunk = chunk.strip() if "-" in chunk: first, last = chunk.split("-", 1) ports.extend(range(int(first), int(last) + 1)) elif chunk: ports.append(int(chunk)) return ports def serve_ping(ports, host): """The client asks http:///server-ping for its server list and reads lines of port~mode~x~y. 'Offline' skips the per-server ping, which is what we want: the stand-in has nothing to measure.""" body = "".join("%d~Offline~0~0\n" % port for port in ports) try: from http.server import BaseHTTPRequestHandler, HTTPServer except ImportError: log("ping", "http.server unavailable, ping endpoint disabled") return None class Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 - required by BaseHTTPRequestHandler payload = body.encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, fmt, *args): log("ping", self.path) try: httpd = HTTPServer((host, 80), Handler) except OSError as exc: log("ping", "cannot listen on port 80 (%s); list will stay empty" % exc) return None threading.Thread(target=httpd.serve_forever, daemon=True).start() log("ping", "serving /server-ping on %s:80 with %d server(s)" % (host, len(ports))) return httpd def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default="0.0.0.0") parser.add_argument("--ports", default="25566", help="ports or ranges, e.g. 25566-25573") parser.add_argument("--no-http", action="store_true", help="do not answer the client server-ping request") args = parser.parse_args() ports = parse_ports(args.ports) listeners = [] for port in ports: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind((args.host, port)) except OSError as exc: log("server", "cannot bind %s:%d (%s)" % (args.host, port, exc)) sock.close() continue sock.listen(8) listeners.append(sock) log("server", "listening on %s:%d, any nick/token accepted", args.host, port) if not args.no_http: serve_ping(ports, args.host) try: while True: ready, _, _ = select.select(listeners, [], [], 1.0) for listener in ready: sock, peer = listener.accept() conn = Conn(sock, "%s:%s" % peer) threading.Thread(target=handle, args=(conn,), daemon=True).start() except KeyboardInterrupt: log("server", "interrupted") finally: for listener in listeners: listener.close() if __name__ == "__main__": main()