From f1152cfb254a9f29a38841d841ad326e8a3ce1a2 Mon Sep 17 00:00:00 2001 From: Sunpy Date: Wed, 15 Mar 2023 07:12:47 +0100 Subject: [PATCH] Server with query protocol --- sampy/__main__.py | 8 +++ sampy/client/player.py | 15 ++++ sampy/config.py | 79 +++++++++++++++++++++ sampy/network/__init__.py | 0 sampy/network/game.py | 12 ++++ sampy/network/protocol.py | 26 +++++++ sampy/network/query.py | 145 ++++++++++++++++++++++++++++++++++++++ sampy/server.py | 63 +++++++++++++++++ 8 files changed, 348 insertions(+) create mode 100644 sampy/client/player.py create mode 100644 sampy/config.py create mode 100644 sampy/network/__init__.py create mode 100644 sampy/network/game.py create mode 100644 sampy/network/protocol.py create mode 100644 sampy/network/query.py create mode 100644 sampy/server.py diff --git a/sampy/__main__.py b/sampy/__main__.py index 8ed1da4..b700fbb 100644 --- a/sampy/__main__.py +++ b/sampy/__main__.py @@ -1,8 +1,16 @@ import argparse +import asyncio import textwrap def main(args: argparse.Namespace) -> int: + from sampy.network.protocol import Protocol + from sampy.server import Server + + server = Server(Protocol) + server.start() + + asyncio.get_event_loop().run_forever() return 0 diff --git a/sampy/client/player.py b/sampy/client/player.py new file mode 100644 index 0000000..704b17f --- /dev/null +++ b/sampy/client/player.py @@ -0,0 +1,15 @@ +from ctypes import c_ubyte + + +class Player: + id: c_ubyte + username: str + score: int + ping: int + health: float + armor: float + # position: Vector3 # TODO + rotation: float + + def __init__(self, username: str): + self.username = username diff --git a/sampy/config.py b/sampy/config.py new file mode 100644 index 0000000..892e92a --- /dev/null +++ b/sampy/config.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +from configparser import ConfigParser +from typing import Dict, Mapping, Union + + +class Config(ConfigParser): + DEFAULTS: Mapping[str, Mapping[str, Union[str, int]]] = { + "sampy": { + "host": "0.0.0.0", + "port": 7777, + "hostname": "Python > C", + "password": "changeme", + "rcon_password": "changeme too", + "max_players": 50, + "mode": "Unknown", + "language": "python", + }, + "sampy.rules": { + "weburl": "https://git.osufx.com/Sunpy/sampy", + }, + "logging": { + "filename": "", + "level": "INFO", + "datefmt": r"%%d-%%b-%%y %%H:%%M:%%S", + }, + } + + def __init__(self, *filenames): + super().__init__() + + self.read_dict(self.DEFAULTS) + + found = self.read(filenames) + missing = set(filenames) - set(found) + + if len(missing): + logging.warn("Config files not found: %s" % missing) + + def save(self, path: str): + with open(path, "w") as f: + self.write(f) + + @property + def host(self) -> str: + return self.get("sampy", "host") + + @property + def port(self) -> int: + return self.getint("sampy", "port") + + @property + def hostname(self) -> str: + return self.get("sampy", "hostname") + + @property + def password(self) -> str: + return self.get("sampy", "password") + + @property + def rcon_password(self) -> str: + return self.get("sampy", "rcon_password") + + @property + def max_players(self) -> int: + return self.getint("sampy", "max_players") + + @property + def mode(self) -> str: + return self.get("sampy", "mode") + + @property + def language(self) -> str: + return self.get("sampy", "language") + + @property + def rules(self) -> Dict[str, str]: + return self["sampy.rules"] diff --git a/sampy/network/__init__.py b/sampy/network/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sampy/network/game.py b/sampy/network/game.py new file mode 100644 index 0000000..38c12b8 --- /dev/null +++ b/sampy/network/game.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: + from sampy.server import Server + + +class Game: + @staticmethod + async def on_packet(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool: + return False diff --git a/sampy/network/protocol.py b/sampy/network/protocol.py new file mode 100644 index 0000000..6302d21 --- /dev/null +++ b/sampy/network/protocol.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Tuple + +from sampy.network.game import Game +from sampy.network.query import Query + +if TYPE_CHECKING: + from sampy.server import Server + + +class Protocol: + VERSION = "0.3.7" + + @staticmethod + async def on_packet(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool: + logging.debug("on_packet") + + if await Query.on_packet(server, packet, addr): + return True + if await Game.on_packet(server, packet, addr): + return True + + logging.debug("Unhandled: %r" % packet) + return False diff --git a/sampy/network/query.py b/sampy/network/query.py new file mode 100644 index 0000000..fdd421b --- /dev/null +++ b/sampy/network/query.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import functools +import logging +import struct +from typing import TYPE_CHECKING, Callable, Dict, Tuple + +if TYPE_CHECKING: + from sampy.server import Server + + +HANDLERS: Dict[int, Callable[[Server, bytes, Tuple[str, int]], bool]] = {} + + +def handler(opcode: bytes): + if len(opcode) > 1: + raise Exception("Query opcode length cannot be bigger then 1") + + def outer(func): + @functools.wraps(func) + def inner( + server: Server, packet: bytes, addr: Tuple[str, int], *args, **kwargs + ): + return func(server, packet, addr, *args, **kwargs) + + HANDLERS[opcode[0]] = func + logging.debug("Added query handler:", opcode, func) + return inner + + return outer + + +class Query: + HEADER_LENGTH = 11 + + @staticmethod + async def on_packet(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool: + if len(packet) < 11: # Packet is too small + return False + + magic, _ip, _port, opcode = struct.unpack( + b"<4sIHB", packet[: Query.HEADER_LENGTH] + ) # Unpack packet + + if magic != b"SAMP": # Validate magic + return False + + return HANDLERS.get(opcode, lambda *_: False)(server, packet, addr) + + @handler(b"i") + @staticmethod + def info(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool: + packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed + + len_hostname = len(server.config.hostname) + len_mode = len(server.config.mode) + len_language = len(server.config.language) + + packet += struct.pack( + b" bool: + packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed + + rules = server.config.rules + rules["version"] = server.protocol.VERSION # Add game version (read-only) + rules["worldtime"] = "" + rules["weather"] = "10" + + packet += struct.pack(b" bool: + packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed + + players = server.players + packet += struct.pack(b" bool: + packet = packet[: Query.HEADER_LENGTH] # Discard additional data if passed + + players = server.players + packet += struct.pack(b" bool: + if ( + len(packet) < Query.HEADER_LENGTH + 4 + ): # Packet is too small (Missing random) + return False + + packet = packet[ + : Query.HEADER_LENGTH + 4 + ] # Discard additional data if passed (+4 to include random) + server.sendto(packet, addr) + return True + + @handler(b"x") + @staticmethod + def rcon(server: Server, packet: bytes, addr: Tuple[str, int]) -> bool: + return False # TODO diff --git a/sampy/server.py b/sampy/server.py new file mode 100644 index 0000000..03ddc21 --- /dev/null +++ b/sampy/server.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Tuple, Type + +from sampy.client.player import Player +from sampy.config import Config + +if TYPE_CHECKING: + from sampy.network.protocol import Protocol + + +class UDPProtocol: + transport: asyncio.transports.DatagramTransport + + def __init__(self, protocol: Protocol, local_addr: Tuple[str, int]): + self.protocol = protocol + self.local_addr = local_addr + + def start(self): + loop = asyncio.get_event_loop() + connect = loop.create_datagram_endpoint( + lambda: self, + local_addr=self.local_addr, + ) + loop.run_until_complete(connect) + + def stop(self): # TODO: Shutdown code + if self.transport is None: + raise Exception("Cannot stop a server that hasn't been started") + + self.transport.close() + + def connection_made(self, transport: asyncio.transports.DatagramTransport): + self.transport = transport + + def datagram_received(self, data: bytes, addr: Tuple[str, int]): + raise NotImplementedError + + def sendto(self, data: bytes | bytearray | memoryview, addr: Tuple[str, int]): + self.transport.sendto(data, addr) + + +class Server(UDPProtocol): + config: Config + + def __init__(self, protocol: Type[Protocol], config: Config = Config()): + super().__init__( + protocol=protocol(), + local_addr=( + config.get("sampy", "host"), + config.getint("sampy", "port"), + ), + ) + self.config = config + + def datagram_received(self, data: bytes, addr: Tuple[str, int]): + loop = asyncio.get_event_loop() + loop.create_task(self.protocol.on_packet(self, data, addr)) + + @property + def players(self) -> list[Player]: # TODO + return []