├── .gitignore ├── LICENSE ├── README.md ├── a2s ├── __init__.py ├── a2s_async.py ├── a2s_fragment.py ├── a2s_sync.py ├── byteio.py ├── defaults.py ├── exceptions.py ├── info.py ├── players.py └── rules.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | build 3 | dist 4 | *.egg-info 5 | venv 6 | .venv 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Gabriel Huber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python A2S 2 | 3 | Library to query Source and GoldSource servers. 4 | Implements [Valve's Server Query Protocol](https://developer.valvesoftware.com/wiki/Server_queries). 5 | Rewrite of the [python-valve](https://github.com/serverstf/python-valve) module. 6 | Supports both synchronous and asyncronous applications. 7 | 8 | Official demo application: [Sourcequery](https://sourcequery.yepoleb.at) 9 | 10 | ## Requirements 11 | 12 | Python >=3.9, no external dependencies 13 | 14 | ## Install 15 | 16 | `pip3 install python-a2s` or `python3 setup.py install` 17 | 18 | ## API 19 | 20 | ### Functions 21 | 22 | * `a2s.info(address, timeout=DEFAULT_TIMEOUT, encoding=DEFAULT_ENCODING)` 23 | * `a2s.players(address, timeout=DEFAULT_TIMEOUT, encoding=DEFAULT_ENCODING)` 24 | * `a2s.rules(address, timeout=DEFAULT_TIMEOUT, encoding=DEFAULT_ENCODING)` 25 | 26 | All functions also have an async version as of package 1.2.0 that adds an `a` prefix, e.g. 27 | `ainfo`, `aplayers`, `arules`. 28 | 29 | ### Parameters 30 | 31 | * address: `Tuple[str, int]` - Address of the server. 32 | * timeout: `float` - Timeout in seconds. Default: 3.0 33 | * encoding: `str` or `None` - String encoding, None disables string decoding. Default: utf-8 34 | 35 | ### Return Values 36 | 37 | * info: SourceInfo or GoldSrcInfo. They are documented in the 38 | [source file](a2s/info.py). 39 | * players: List of Player items. Also documented in the corresponding 40 | [source file](a2s/players.py). 41 | * rules: Dictionary of key - value pairs. 42 | 43 | ### Exceptions 44 | 45 | * `a2s.BrokenMessageError(Exception)` - General decoding error 46 | * `a2s.BufferExhaustedError(BrokenMessageError)` - Response too short 47 | * `socket.timeout` - No response (synchronous calls) 48 | * `asyncio.exceptions.TimeoutError` - No response (async calls) 49 | * `socket.gaierror` - Address resolution error 50 | * `ConnectionRefusedError` - Target port closed 51 | * `OSError` - Various networking errors like routing failure 52 | 53 | ## Examples 54 | 55 | Example output shown may be shortened. Also the server shown in the example may be down by the time you see this. 56 | 57 | ```py 58 | >>> import a2s 59 | >>> address = ("chi-1.us.uncletopia.com", 27015) 60 | >>> a2s.info(address) 61 | SourceInfo(protocol=17, server_name='Uncletopia | Chicago | 1', map_name='pl_badwater', 62 | folder='tf', game='Team Fortress', app_id=440, player_count=24, max_players=24, bot_count=0, 63 | server_type='d', platform='l', password_protected=False, vac_enabled=True, version='7370160', 64 | edf=241, port=27015, steam_id=85568392924469984, stv_port=27016, 65 | stv_name='Uncletopia | Chicago | 1 | STV', keywords='nocrits,nodmgspread,payload,uncletopia', 66 | game_id=440, ping=0.2339219159912318) 67 | 68 | >>> a2s.players(address) 69 | [Player(index=0, name='AmNot', score=22, duration=8371.4072265625), 70 | Player(index=0, name='TAAAAANK!', score=15, duration=6251.03173828125), 71 | Player(index=0, name='Tiny Baby Man', score=17, duration=6229.0361328125)] 72 | 73 | >>> a2s.rules(address) 74 | {'coop': '0', 'cronjobs_version': '2.0', 'crontab_version': '2.0', 'deathmatch': '1', 75 | 'decalfrequency': '10', 'discord_accelerator_version': '1.0', 'discord_version': '1.0', 76 | 'extendedmapconfig_version': '1.1.1', 'metamod_version': '1.11.0-dev+1145V', 'mp_allowNPCs': '1'} 77 | ``` 78 | 79 | ## Notes 80 | 81 | * Some servers return inconsistent or garbage data. Filtering this out is left to the specific application, because there is no general approach to filtering that makes sense for all use cases. In most scenarios, it makes sense to at least remove players with empty names. Also the `player_count` value in the info query and the actual number of players returned in the player query do not always match up. Sometimes the player query returns an empty list of players. 82 | 83 | * For some games, the query port is different from the actual connection port. The Steam server browser will show the connection port and querying that will not return an answer. There does not seem to be a general solution to this problem so far, but usually probing port numbers up to 10 higher and lower than the connection port usually leads to a response. There's also the option of using `http://api.steampowered.com/ISteamApps/GetServersAtAddress/v0001?addr={IP}` to get a list of game servers on an IP (thanks to Nereg for this suggestion). If you're still not successful, use a network sniffer like Wireshark to monitor outgoing packets while refreshing the server popup in Steam. 84 | 85 | * Player counts above 255 do not work and there's no way to make them work. This is a limitation in the specification of the protocol. 86 | 87 | * This library does not implement rate limiting. It's up to the application to limit the number of requests per second to an acceptable amount to not trigger any firewall rules. 88 | 89 | ## Tested Games 90 | 91 | Half-Life 2, Half-Life, Team Fortress 2, Counter-Strike: Global Offensive, Counter-Strike 1.6, ARK: Survival Evolved, Rust 92 | 93 | ## Similar Projects 94 | 95 | * [dayzquery](https://github.com/Yepoleb/dayzquery) - Module for decoding DayZ rules responses 96 | * [l4d2query](https://github.com/Yepoleb/l4d2query) - Module for querying additional data from L4D2 servers 97 | 98 | ## License 99 | 100 | MIT 101 | -------------------------------------------------------------------------------- /a2s/__init__.py: -------------------------------------------------------------------------------- 1 | from a2s.exceptions import BrokenMessageError, BufferExhaustedError 2 | 3 | from a2s.info import info, ainfo, SourceInfo, GoldSrcInfo 4 | from a2s.players import players, aplayers, Player 5 | from a2s.rules import rules, arules 6 | -------------------------------------------------------------------------------- /a2s/a2s_async.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | import io 5 | 6 | from a2s.exceptions import BrokenMessageError 7 | from a2s.a2s_fragment import decode_fragment 8 | from a2s.defaults import DEFAULT_RETRIES 9 | from a2s.byteio import ByteReader 10 | 11 | 12 | 13 | HEADER_SIMPLE = b"\xFF\xFF\xFF\xFF" 14 | HEADER_MULTI = b"\xFE\xFF\xFF\xFF" 15 | A2S_CHALLENGE_RESPONSE = 0x41 16 | 17 | logger = logging.getLogger("a2s") 18 | 19 | 20 | async def request_async(address, timeout, encoding, a2s_proto): 21 | conn = await A2SStreamAsync.create(address, timeout) 22 | response = await request_async_impl(conn, encoding, a2s_proto) 23 | conn.close() 24 | return response 25 | 26 | async def request_async_impl(conn, encoding, a2s_proto, challenge=0, retries=0, ping=None): 27 | send_time = time.monotonic() 28 | resp_data = await conn.request(a2s_proto.serialize_request(challenge)) 29 | recv_time = time.monotonic() 30 | # Only set ping on first packet received 31 | if retries == 0: 32 | ping = recv_time - send_time 33 | 34 | reader = ByteReader( 35 | io.BytesIO(resp_data), endian="<", encoding=encoding) 36 | 37 | response_type = reader.read_uint8() 38 | if response_type == A2S_CHALLENGE_RESPONSE: 39 | if retries >= DEFAULT_RETRIES: 40 | raise BrokenMessageError( 41 | "Server keeps sending challenge responses") 42 | challenge = reader.read_uint32() 43 | return await request_async_impl( 44 | conn, encoding, a2s_proto, challenge, retries + 1, ping) 45 | 46 | if not a2s_proto.validate_response_type(response_type): 47 | raise BrokenMessageError( 48 | "Invalid response type: " + hex(response_type)) 49 | 50 | return a2s_proto.deserialize_response(reader, response_type, ping) 51 | 52 | 53 | class A2SProtocol(asyncio.DatagramProtocol): 54 | def __init__(self): 55 | self.recv_queue = asyncio.Queue() 56 | self.error_event = asyncio.Event() 57 | self.error = None 58 | self.fragment_buf = [] 59 | 60 | def connection_made(self, transport): 61 | self.transport = transport 62 | 63 | def datagram_received(self, packet, addr): 64 | header = packet[:4] 65 | payload = packet[4:] 66 | if header == HEADER_SIMPLE: 67 | logger.debug("Received single packet: %r", payload) 68 | self.recv_queue.put_nowait(payload) 69 | elif header == HEADER_MULTI: 70 | self.fragment_buf.append(decode_fragment(payload)) 71 | if len(self.fragment_buf) < self.fragment_buf[0].fragment_count: 72 | return # Wait for more packets to arrive 73 | self.fragment_buf.sort(key=lambda f: f.fragment_id) 74 | reassembled = b"".join( 75 | fragment.payload for fragment in self.fragment_buf) 76 | # Sometimes there's an additional header present 77 | if reassembled.startswith(b"\xFF\xFF\xFF\xFF"): 78 | reassembled = reassembled[4:] 79 | logger.debug("Received %s part packet with content: %r", 80 | len(self.fragment_buf), reassembled) 81 | self.recv_queue.put_nowait(reassembled) 82 | self.fragment_buf = [] 83 | else: 84 | self.error = BrokenMessageError( 85 | "Invalid packet header: " + repr(header)) 86 | self.error_event.set() 87 | 88 | def error_received(self, exc): 89 | self.error = exc 90 | self.error_event.set() 91 | 92 | def raise_on_error(self): 93 | error = self.error 94 | self.error = None 95 | self.error_event.clear() 96 | raise error 97 | 98 | class A2SStreamAsync: 99 | def __init__(self, transport, protocol, timeout): 100 | self.transport = transport 101 | self.protocol = protocol 102 | self.timeout = timeout 103 | 104 | def __del__(self): 105 | self.close() 106 | 107 | @classmethod 108 | async def create(cls, address, timeout): 109 | loop = asyncio.get_running_loop() 110 | transport, protocol = await loop.create_datagram_endpoint( 111 | lambda: A2SProtocol(), remote_addr=address) 112 | return cls(transport, protocol, timeout) 113 | 114 | def send(self, payload): 115 | logger.debug("Sending packet: %r", payload) 116 | packet = HEADER_SIMPLE + payload 117 | self.transport.sendto(packet) 118 | 119 | async def recv(self): 120 | queue_task = asyncio.create_task(self.protocol.recv_queue.get()) 121 | error_task = asyncio.create_task(self.protocol.error_event.wait()) 122 | done, pending = await asyncio.wait({queue_task, error_task}, 123 | timeout=self.timeout, return_when=asyncio.FIRST_COMPLETED) 124 | 125 | for task in pending: task.cancel() 126 | if error_task in done: 127 | self.protocol.raise_on_error() 128 | if not done: 129 | raise asyncio.TimeoutError() 130 | 131 | return queue_task.result() 132 | 133 | async def request(self, payload): 134 | self.send(payload) 135 | return await self.recv() 136 | 137 | def close(self): 138 | self.transport.close() 139 | -------------------------------------------------------------------------------- /a2s/a2s_fragment.py: -------------------------------------------------------------------------------- 1 | import bz2 2 | import io 3 | 4 | from a2s.byteio import ByteReader 5 | 6 | 7 | 8 | class A2SFragment: 9 | def __init__(self, message_id, fragment_count, fragment_id, mtu, 10 | decompressed_size=0, crc=0, payload=b""): 11 | self.message_id = message_id 12 | self.fragment_count = fragment_count 13 | self.fragment_id = fragment_id 14 | self.mtu = mtu 15 | self.decompressed_size = decompressed_size 16 | self.crc = crc 17 | self.payload = payload 18 | 19 | @property 20 | def is_compressed(self): 21 | return bool(self.message_id & (1 << 15)) 22 | 23 | def decode_fragment(data): 24 | reader = ByteReader( 25 | io.BytesIO(data), endian="<", encoding="utf-8") 26 | frag = A2SFragment( 27 | message_id=reader.read_uint32(), 28 | fragment_count=reader.read_uint8(), 29 | fragment_id=reader.read_uint8(), 30 | mtu=reader.read_uint16() 31 | ) 32 | if frag.is_compressed: 33 | frag.decompressed_size = reader.read_uint32() 34 | frag.crc = reader.read_uint32() 35 | frag.payload = bz2.decompress(reader.read()) 36 | else: 37 | frag.payload = reader.read() 38 | 39 | return frag 40 | -------------------------------------------------------------------------------- /a2s/a2s_sync.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import logging 3 | import time 4 | import io 5 | 6 | from a2s.exceptions import BrokenMessageError 7 | from a2s.a2s_fragment import decode_fragment 8 | from a2s.defaults import DEFAULT_RETRIES 9 | from a2s.byteio import ByteReader 10 | 11 | 12 | 13 | HEADER_SIMPLE = b"\xFF\xFF\xFF\xFF" 14 | HEADER_MULTI = b"\xFE\xFF\xFF\xFF" 15 | A2S_CHALLENGE_RESPONSE = 0x41 16 | 17 | logger = logging.getLogger("a2s") 18 | 19 | 20 | def request_sync(address, timeout, encoding, a2s_proto): 21 | conn = A2SStream(address, timeout) 22 | response = request_sync_impl(conn, encoding, a2s_proto) 23 | conn.close() 24 | return response 25 | 26 | def request_sync_impl(conn, encoding, a2s_proto, challenge=0, retries=0, ping=None): 27 | send_time = time.monotonic() 28 | resp_data = conn.request(a2s_proto.serialize_request(challenge)) 29 | recv_time = time.monotonic() 30 | # Only set ping on first packet received 31 | if retries == 0: 32 | ping = recv_time - send_time 33 | 34 | reader = ByteReader( 35 | io.BytesIO(resp_data), endian="<", encoding=encoding) 36 | 37 | response_type = reader.read_uint8() 38 | if response_type == A2S_CHALLENGE_RESPONSE: 39 | if retries >= DEFAULT_RETRIES: 40 | raise BrokenMessageError( 41 | "Server keeps sending challenge responses") 42 | challenge = reader.read_uint32() 43 | return request_sync_impl( 44 | conn, encoding, a2s_proto, challenge, retries + 1, ping) 45 | 46 | if not a2s_proto.validate_response_type(response_type): 47 | raise BrokenMessageError( 48 | "Invalid response type: " + hex(response_type)) 49 | 50 | return a2s_proto.deserialize_response(reader, response_type, ping) 51 | 52 | 53 | class A2SStream: 54 | def __init__(self, address, timeout): 55 | self.address = address 56 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 57 | self._socket.settimeout(timeout) 58 | 59 | def __del__(self): 60 | self.close() 61 | 62 | def send(self, data): 63 | logger.debug("Sending packet: %r", data) 64 | packet = HEADER_SIMPLE + data 65 | self._socket.sendto(packet, self.address) 66 | 67 | def recv(self): 68 | packet = self._socket.recv(65535) 69 | header = packet[:4] 70 | data = packet[4:] 71 | if header == HEADER_SIMPLE: 72 | logger.debug("Received single packet: %r", data) 73 | return data 74 | elif header == HEADER_MULTI: 75 | fragments = [decode_fragment(data)] 76 | while len(fragments) < fragments[0].fragment_count: 77 | packet = self._socket.recv(4096) 78 | fragments.append(decode_fragment(packet[4:])) 79 | fragments.sort(key=lambda f: f.fragment_id) 80 | reassembled = b"".join(fragment.payload for fragment in fragments) 81 | # Sometimes there's an additional header present 82 | if reassembled.startswith(b"\xFF\xFF\xFF\xFF"): 83 | reassembled = reassembled[4:] 84 | logger.debug("Received %s part packet with content: %r", 85 | len(fragments), reassembled) 86 | return reassembled 87 | else: 88 | raise BrokenMessageError( 89 | "Invalid packet header: " + repr(header)) 90 | 91 | def request(self, payload): 92 | self.send(payload) 93 | return self.recv() 94 | 95 | def close(self): 96 | self._socket.close() 97 | -------------------------------------------------------------------------------- /a2s/byteio.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import io 3 | 4 | from a2s.exceptions import BufferExhaustedError 5 | 6 | 7 | 8 | class ByteReader(): 9 | def __init__(self, stream, endian="=", encoding=None): 10 | self.stream = stream 11 | self.endian = endian 12 | self.encoding = encoding 13 | 14 | def read(self, size=-1): 15 | data = self.stream.read(size) 16 | if size > -1 and len(data) != size: 17 | raise BufferExhaustedError() 18 | 19 | return data 20 | 21 | def peek(self, size=-1): 22 | cur_pos = self.stream.tell() 23 | data = self.stream.read(size) 24 | self.stream.seek(cur_pos, io.SEEK_SET) 25 | return data 26 | 27 | def unpack(self, fmt): 28 | fmt = self.endian + fmt 29 | fmt_size = struct.calcsize(fmt) 30 | return struct.unpack(fmt, self.read(fmt_size)) 31 | 32 | def unpack_one(self, fmt): 33 | values = self.unpack(fmt) 34 | assert len(values) == 1 35 | return values[0] 36 | 37 | def read_int8(self): 38 | return self.unpack_one("b") 39 | 40 | def read_uint8(self): 41 | return self.unpack_one("B") 42 | 43 | def read_int16(self): 44 | return self.unpack_one("h") 45 | 46 | def read_uint16(self): 47 | return self.unpack_one("H") 48 | 49 | def read_int32(self): 50 | return self.unpack_one("l") 51 | 52 | def read_uint32(self): 53 | return self.unpack_one("L") 54 | 55 | def read_int64(self): 56 | return self.unpack_one("q") 57 | 58 | def read_uint64(self): 59 | return self.unpack_one("Q") 60 | 61 | def read_float(self): 62 | return self.unpack_one("f") 63 | 64 | def read_double(self): 65 | return self.unpack_one("d") 66 | 67 | def read_bool(self): 68 | return bool(self.unpack_one("b")) 69 | 70 | def read_char(self): 71 | char = self.unpack_one("c") 72 | if self.encoding is not None: 73 | return char.decode(self.encoding, errors="replace") 74 | else: 75 | return char 76 | 77 | def read_cstring(self, charsize=1): 78 | string = b"" 79 | while True: 80 | c = self.read(charsize) 81 | if int.from_bytes(c, "little") == 0: 82 | break 83 | else: 84 | string += c 85 | 86 | if self.encoding is not None: 87 | return string.decode(self.encoding, errors="replace") 88 | else: 89 | return string 90 | 91 | 92 | class ByteWriter(): 93 | def __init__(self, stream, endian="=", encoding=None): 94 | self.stream = stream 95 | self.endian = endian 96 | self.encoding = encoding 97 | 98 | def write(self, *args): 99 | return self.stream.write(*args) 100 | 101 | def pack(self, fmt, *values): 102 | fmt = self.endian + fmt 103 | fmt_size = struct.calcsize(fmt) 104 | return self.stream.write(struct.pack(fmt, *values)) 105 | 106 | def write_int8(self, val): 107 | self.pack("b", val) 108 | 109 | def write_uint8(self, val): 110 | self.pack("B", val) 111 | 112 | def write_int16(self, val): 113 | self.pack("h", val) 114 | 115 | def write_uint16(self, val): 116 | self.pack("H", val) 117 | 118 | def write_int32(self, val): 119 | self.pack("l", val) 120 | 121 | def write_uint32(self, val): 122 | self.pack("L", val) 123 | 124 | def write_int64(self, val): 125 | self.pack("q", val) 126 | 127 | def write_uint64(self, val): 128 | self.pack("Q", val) 129 | 130 | def write_float(self, val): 131 | self.pack("f", val) 132 | 133 | def write_double(self, val): 134 | self.pack("d", val) 135 | 136 | def write_bool(self, val): 137 | self.pack("b", val) 138 | 139 | def write_char(self, val): 140 | if self.encoding is not None: 141 | self.pack("c", val.encode(self.encoding)) 142 | else: 143 | self.pack("c", val) 144 | 145 | def write_cstring(self, val): 146 | if self.encoding is not None: 147 | self.write(val.encode(self.encoding) + b"\x00") 148 | else: 149 | self.write(val + b"\x00") 150 | -------------------------------------------------------------------------------- /a2s/defaults.py: -------------------------------------------------------------------------------- 1 | DEFAULT_TIMEOUT = 3.0 2 | DEFAULT_ENCODING = "utf-8" 3 | DEFAULT_RETRIES = 5 4 | -------------------------------------------------------------------------------- /a2s/exceptions.py: -------------------------------------------------------------------------------- 1 | class BrokenMessageError(Exception): 2 | pass 3 | 4 | class BufferExhaustedError(BrokenMessageError): 5 | pass 6 | -------------------------------------------------------------------------------- /a2s/info.py: -------------------------------------------------------------------------------- 1 | import io 2 | from dataclasses import dataclass 3 | from typing import Optional, Generic, Union, TypeVar, overload 4 | 5 | from a2s.exceptions import BrokenMessageError, BufferExhaustedError 6 | from a2s.defaults import DEFAULT_TIMEOUT, DEFAULT_ENCODING 7 | from a2s.a2s_sync import request_sync 8 | from a2s.a2s_async import request_async 9 | from a2s.byteio import ByteReader 10 | 11 | 12 | 13 | A2S_INFO_RESPONSE = 0x49 14 | A2S_INFO_RESPONSE_LEGACY = 0x6D 15 | 16 | 17 | StrType = TypeVar("StrType", str, bytes) # str (default) or bytes if encoding=None is used 18 | 19 | @dataclass 20 | class SourceInfo(Generic[StrType]): 21 | protocol: int 22 | """Protocol version used by the server""" 23 | 24 | server_name: StrType 25 | """Display name of the server""" 26 | 27 | map_name: StrType 28 | """The currently loaded map""" 29 | 30 | folder: StrType 31 | """Name of the game directory""" 32 | 33 | game: StrType 34 | """Name of the game""" 35 | 36 | app_id: int 37 | """App ID of the game required to connect""" 38 | 39 | player_count: int 40 | """Number of players currently connected""" 41 | 42 | max_players: int 43 | """Number of player slots available""" 44 | 45 | bot_count: int 46 | """Number of bots on the server""" 47 | 48 | server_type: StrType 49 | """Type of the server: 50 | 'd': Dedicated server 51 | 'l': Non-dedicated server 52 | 'p': SourceTV relay (proxy)""" 53 | 54 | platform: StrType 55 | """Operating system of the server 56 | 'l', 'w', 'm' for Linux, Windows, macOS""" 57 | 58 | password_protected: bool 59 | """Server requires a password to connect""" 60 | 61 | vac_enabled: bool 62 | """Server has VAC enabled""" 63 | 64 | version: StrType 65 | """Version of the server software""" 66 | 67 | edf: int 68 | """Extra data field, used to indicate if extra values are included in the response""" 69 | 70 | ping: float 71 | """Round-trip time for the request in seconds, not actually sent by the server""" 72 | 73 | # Optional: 74 | port: Optional[int] = None 75 | """Port of the game server.""" 76 | 77 | steam_id: Optional[int] = None 78 | """Steam ID of the server""" 79 | 80 | stv_port: Optional[int] = None 81 | """Port of the SourceTV server""" 82 | 83 | stv_name: Optional[StrType] = None 84 | """Name of the SourceTV server""" 85 | 86 | keywords: Optional[StrType] = None 87 | """Tags that describe the gamemode being played""" 88 | 89 | game_id: Optional[int] = None 90 | """Game ID for games that have an app ID too high for 16bit.""" 91 | 92 | @property 93 | def has_port(self): 94 | return bool(self.edf & 0x80) 95 | 96 | @property 97 | def has_steam_id(self): 98 | return bool(self.edf & 0x10) 99 | 100 | @property 101 | def has_stv(self): 102 | return bool(self.edf & 0x40) 103 | 104 | @property 105 | def has_keywords(self): 106 | return bool(self.edf & 0x20) 107 | 108 | @property 109 | def has_game_id(self): 110 | return bool(self.edf & 0x01) 111 | 112 | @dataclass 113 | class GoldSrcInfo(Generic[StrType]): 114 | address: StrType 115 | """IP Address and port of the server""" 116 | 117 | server_name: StrType 118 | """Display name of the server""" 119 | 120 | map_name: StrType 121 | """The currently loaded map""" 122 | 123 | folder: StrType 124 | """Name of the game directory""" 125 | 126 | game: StrType 127 | """Name of the game""" 128 | 129 | player_count: int 130 | """Number of players currently connected""" 131 | 132 | max_players: int 133 | """Number of player slots available""" 134 | 135 | protocol: int 136 | """Protocol version used by the server""" 137 | 138 | server_type: StrType 139 | """Type of the server: 140 | 'd': Dedicated server 141 | 'l': Non-dedicated server 142 | 'p': SourceTV relay (proxy)""" 143 | 144 | platform: StrType 145 | """Operating system of the server 146 | 'l', 'w' for Linux and Windows""" 147 | 148 | password_protected: bool 149 | """Server requires a password to connect""" 150 | 151 | is_mod: bool 152 | """Server is running a Half-Life mod instead of the base game""" 153 | 154 | vac_enabled: bool 155 | """Server has VAC enabled""" 156 | 157 | bot_count: int 158 | """Number of bots on the server""" 159 | 160 | ping: float 161 | """Round-trip time for the request in seconds, not actually sent by the server""" 162 | 163 | # Optional: 164 | mod_website: Optional[StrType] 165 | """URL to the mod website""" 166 | 167 | mod_download: Optional[StrType] 168 | """URL to download the mod""" 169 | 170 | mod_version: Optional[int] 171 | """Version of the mod installed on the server""" 172 | 173 | mod_size: Optional[int] 174 | """Size in bytes of the mod""" 175 | 176 | multiplayer_only: Optional[bool] 177 | """Mod supports multiplayer only""" 178 | 179 | uses_custom_dll: Optional[bool] 180 | """Mod uses a custom DLL""" 181 | 182 | @property 183 | def uses_hl_dll(self) -> Optional[bool]: 184 | """Compatibility alias, because it got renamed""" 185 | return self.uses_custom_dll 186 | 187 | 188 | @overload 189 | def info(address: tuple[str, int], timeout: float, encoding: str) -> Union[SourceInfo[str], GoldSrcInfo[str]]: 190 | ... 191 | 192 | @overload 193 | def info(address: tuple[str, int], timeout: float, encoding: None) -> Union[SourceInfo[bytes], GoldSrcInfo[bytes]]: 194 | ... 195 | 196 | def info( 197 | address: tuple[str, int], 198 | timeout: float = DEFAULT_TIMEOUT, 199 | encoding: Union[str, None] = DEFAULT_ENCODING 200 | ) -> Union[SourceInfo[str], SourceInfo[bytes], GoldSrcInfo[str], GoldSrcInfo[bytes]]: 201 | return request_sync(address, timeout, encoding, InfoProtocol) 202 | 203 | @overload 204 | async def ainfo(address: tuple[str, int], timeout: float, encoding: str) -> Union[SourceInfo[str], GoldSrcInfo[str]]: 205 | ... 206 | 207 | @overload 208 | async def ainfo(address: tuple[str, int], timeout: float, encoding: None) -> Union[SourceInfo[bytes], GoldSrcInfo[bytes]]: 209 | ... 210 | 211 | async def ainfo( 212 | address: tuple[str, int], 213 | timeout: float = DEFAULT_TIMEOUT, 214 | encoding: Union[str, None] = DEFAULT_ENCODING 215 | ) -> Union[SourceInfo[str], SourceInfo[bytes], GoldSrcInfo[str], GoldSrcInfo[bytes]]: 216 | return await request_async(address, timeout, encoding, InfoProtocol) 217 | 218 | 219 | class InfoProtocol: 220 | @staticmethod 221 | def validate_response_type(response_type): 222 | return response_type in (A2S_INFO_RESPONSE, A2S_INFO_RESPONSE_LEGACY) 223 | 224 | @staticmethod 225 | def serialize_request(challenge): 226 | if challenge: 227 | return b"\x54Source Engine Query\0" + challenge.to_bytes(4, "little") 228 | else: 229 | return b"\x54Source Engine Query\0" 230 | 231 | @staticmethod 232 | def deserialize_response(reader, response_type, ping): 233 | if response_type == A2S_INFO_RESPONSE: 234 | resp = parse_source(reader, ping) 235 | elif response_type == A2S_INFO_RESPONSE_LEGACY: 236 | resp = parse_goldsrc(reader, ping) 237 | else: 238 | raise Exception(str(response_type)) 239 | 240 | return resp 241 | 242 | def parse_source(reader, ping): 243 | protocol = reader.read_uint8() 244 | server_name = reader.read_cstring() 245 | map_name = reader.read_cstring() 246 | folder = reader.read_cstring() 247 | game = reader.read_cstring() 248 | app_id = reader.read_uint16() 249 | player_count = reader.read_uint8() 250 | max_players = reader.read_uint8() 251 | bot_count = reader.read_uint8() 252 | server_type = reader.read_char().lower() 253 | platform = reader.read_char().lower() 254 | if platform == "o": # Deprecated mac value 255 | platform = "m" 256 | password_protected = reader.read_bool() 257 | vac_enabled = reader.read_bool() 258 | version = reader.read_cstring() 259 | 260 | try: 261 | edf = reader.read_uint8() 262 | except BufferExhaustedError: 263 | edf = 0 264 | 265 | resp = SourceInfo( 266 | protocol, server_name, map_name, folder, game, app_id, player_count, max_players, 267 | bot_count, server_type, platform, password_protected, vac_enabled, version, edf, ping 268 | ) 269 | if resp.has_port: 270 | resp.port = reader.read_uint16() 271 | if resp.has_steam_id: 272 | resp.steam_id = reader.read_uint64() 273 | if resp.has_stv: 274 | resp.stv_port = reader.read_uint16() 275 | resp.stv_name = reader.read_cstring() 276 | if resp.has_keywords: 277 | resp.keywords = reader.read_cstring() 278 | if resp.has_game_id: 279 | resp.game_id = reader.read_uint64() 280 | 281 | return resp 282 | 283 | def parse_goldsrc(reader, ping): 284 | address = reader.read_cstring() 285 | server_name = reader.read_cstring() 286 | map_name = reader.read_cstring() 287 | folder = reader.read_cstring() 288 | game = reader.read_cstring() 289 | player_count = reader.read_uint8() 290 | max_players = reader.read_uint8() 291 | protocol = reader.read_uint8() 292 | server_type = reader.read_char() 293 | platform = reader.read_char() 294 | password_protected = reader.read_bool() 295 | is_mod = reader.read_bool() 296 | 297 | # Some games don't send this section 298 | if is_mod and len(reader.peek()) > 2: 299 | mod_website = reader.read_cstring() 300 | mod_download = reader.read_cstring() 301 | reader.read(1) # Skip a NULL byte 302 | mod_version = reader.read_uint32() 303 | mod_size = reader.read_uint32() 304 | multiplayer_only = reader.read_bool() 305 | uses_custom_dll = reader.read_bool() 306 | else: 307 | mod_website = None 308 | mod_download = None 309 | mod_version = None 310 | mod_size = None 311 | multiplayer_only = None 312 | uses_custom_dll = None 313 | 314 | vac_enabled = reader.read_bool() 315 | bot_count = reader.read_uint8() 316 | 317 | return GoldSrcInfo( 318 | address, server_name, map_name, folder, game, player_count, max_players, protocol, 319 | server_type, platform, password_protected, is_mod, vac_enabled, bot_count, mod_website, 320 | mod_download, mod_version, mod_size, multiplayer_only, uses_custom_dll, ping 321 | ) 322 | -------------------------------------------------------------------------------- /a2s/players.py: -------------------------------------------------------------------------------- 1 | import io 2 | from dataclasses import dataclass 3 | from typing import Generic, Union, TypeVar, overload 4 | 5 | from a2s.defaults import DEFAULT_TIMEOUT, DEFAULT_ENCODING 6 | from a2s.a2s_sync import request_sync 7 | from a2s.a2s_async import request_async 8 | from a2s.byteio import ByteReader 9 | 10 | 11 | 12 | A2S_PLAYER_RESPONSE = 0x44 13 | 14 | 15 | StrType = TypeVar("StrType", str, bytes) # str (default) or bytes if encoding=None is used 16 | 17 | @dataclass 18 | class Player(Generic[StrType]): 19 | index: int 20 | """Apparently an entry index, but seems to be always 0""" 21 | 22 | name: StrType 23 | """Name of the player""" 24 | 25 | score: int 26 | """Score of the player""" 27 | 28 | duration: float 29 | """Time the player has been connected to the server""" 30 | 31 | 32 | @overload 33 | def players(address: tuple[str, int], timeout: float, encoding: str) -> list[Player[str]]: 34 | ... 35 | 36 | @overload 37 | def players(address: tuple[str, int], timeout: float, encoding: None) -> list[Player[bytes]]: 38 | ... 39 | 40 | def players( 41 | address: tuple[str, int], 42 | timeout: float = DEFAULT_TIMEOUT, 43 | encoding: Union[str, None] = DEFAULT_ENCODING 44 | ) -> Union[list[Player[str]], list[Player[bytes]]]: 45 | return request_sync(address, timeout, encoding, PlayersProtocol) 46 | 47 | @overload 48 | async def aplayers(address: tuple[str, int], timeout: float, encoding: str) -> list[Player[str]]: 49 | ... 50 | 51 | @overload 52 | async def aplayers(address: tuple[str, int], timeout: float, encoding: None) -> list[Player[bytes]]: 53 | ... 54 | 55 | async def aplayers( 56 | address: tuple[str, int], 57 | timeout: float = DEFAULT_TIMEOUT, 58 | encoding: Union[str, None] = DEFAULT_ENCODING 59 | ) -> Union[list[Player[str]], list[Player[bytes]]]: 60 | return await request_async(address, timeout, encoding, PlayersProtocol) 61 | 62 | 63 | class PlayersProtocol: 64 | @staticmethod 65 | def validate_response_type(response_type): 66 | return response_type == A2S_PLAYER_RESPONSE 67 | 68 | @staticmethod 69 | def serialize_request(challenge): 70 | return b"\x55" + challenge.to_bytes(4, "little") 71 | 72 | @staticmethod 73 | def deserialize_response(reader, response_type, ping): 74 | player_count = reader.read_uint8() 75 | resp = [ 76 | Player( 77 | index=reader.read_uint8(), 78 | name=reader.read_cstring(), 79 | score=reader.read_int32(), 80 | duration=reader.read_float() 81 | ) 82 | for player_num in range(player_count) 83 | ] 84 | return resp 85 | -------------------------------------------------------------------------------- /a2s/rules.py: -------------------------------------------------------------------------------- 1 | import io 2 | from typing import overload, Union 3 | 4 | from a2s.defaults import DEFAULT_TIMEOUT, DEFAULT_ENCODING 5 | from a2s.a2s_sync import request_sync 6 | from a2s.a2s_async import request_async 7 | from a2s.byteio import ByteReader 8 | 9 | 10 | 11 | A2S_RULES_RESPONSE = 0x45 12 | 13 | 14 | @overload 15 | def rules(address: tuple[str, int], timeout: float, encoding: str) -> dict[str, str]: 16 | ... 17 | 18 | @overload 19 | def rules(address: tuple[str, int], timeout: float, encoding: None) -> dict[bytes, bytes]: 20 | ... 21 | 22 | def rules( 23 | address: tuple[str, int], 24 | timeout: float = DEFAULT_TIMEOUT, 25 | encoding: Union[str, None] = DEFAULT_ENCODING 26 | ) -> Union[dict[str, str], dict[bytes, bytes]]: 27 | return request_sync(address, timeout, encoding, RulesProtocol) 28 | 29 | @overload 30 | async def arules(address: tuple[str, int], timeout: float, encoding: str) -> dict[str, str]: 31 | ... 32 | 33 | @overload 34 | async def arules(address: tuple[str, int], timeout: float, encoding: None) -> dict[bytes, bytes]: 35 | ... 36 | 37 | async def arules( 38 | address: tuple[str, int], 39 | timeout: float = DEFAULT_TIMEOUT, 40 | encoding: Union[str, None] = DEFAULT_ENCODING 41 | ) -> Union[dict[str, str], dict[bytes, bytes]]: 42 | return await request_async(address, timeout, encoding, RulesProtocol) 43 | 44 | 45 | class RulesProtocol: 46 | @staticmethod 47 | def validate_response_type(response_type): 48 | return response_type == A2S_RULES_RESPONSE 49 | 50 | @staticmethod 51 | def serialize_request(challenge): 52 | return b"\x56" + challenge.to_bytes(4, "little") 53 | 54 | @staticmethod 55 | def deserialize_response(reader, response_type, ping): 56 | rule_count = reader.read_int16() 57 | # Have to use tuples to preserve evaluation order 58 | resp = dict( 59 | (reader.read_cstring(), reader.read_cstring()) 60 | for rule_num in range(rule_count) 61 | ) 62 | return resp 63 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import setuptools 4 | 5 | with open("README.md", "r") as readme: 6 | long_description = readme.read() 7 | 8 | setuptools.setup( 9 | name="python-a2s", 10 | version="1.4.1", 11 | author="Gabriel Huber", 12 | author_email="mail@gabrielhuber.at", 13 | description="Query Source and GoldSource servers for name, map, players and more.", 14 | long_description=long_description, 15 | long_description_content_type="text/markdown", 16 | url="https://github.com/Yepoleb/python-a2s", 17 | packages=["a2s"], 18 | license="MIT License", 19 | classifiers=[ 20 | "Development Status :: 4 - Beta", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Operating System :: OS Independent", 24 | "Topic :: Games/Entertainment" 25 | ], 26 | python_requires=">=3.9" 27 | ) 28 | --------------------------------------------------------------------------------