commit
cec3571dc9
10 changed files with 507 additions and 0 deletions
@ -0,0 +1 @@ |
|||||
|
__pycache__ |
@ -0,0 +1,22 @@ |
|||||
|
MIT License |
||||
|
|
||||
|
Copyright (c) 2020 Gabriel Huber |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
|
of this software and associated documentation files (the "Software"), to deal |
||||
|
in the Software without restriction, including without limitation the rights |
||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
|
copies of the Software, and to permit persons to whom the Software is |
||||
|
furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all |
||||
|
copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
|
SOFTWARE. |
||||
|
|
@ -0,0 +1,8 @@ |
|||||
|
from a2s.exceptions import BrokenMessageError, BufferExhaustedError |
||||
|
from a2s.defaults import \ |
||||
|
set_default_timeout, get_default_timeout, \ |
||||
|
set_default_encoding, get_default_encoding |
||||
|
|
||||
|
from a2s.info import info, InfoResponse |
||||
|
from a2s.players import players, PlayersResponse, PlayerEntry |
||||
|
from a2s.rules import rules, RulesResponse |
@ -0,0 +1,84 @@ |
|||||
|
import socket |
||||
|
import bz2 |
||||
|
import io |
||||
|
|
||||
|
from a2s.defaults import default_encoding, default_timeout |
||||
|
from a2s.exceptions import BrokenMessageError |
||||
|
from a2s.byteio import ByteReader |
||||
|
|
||||
|
|
||||
|
HEADER_SIMPLE = b"\xFF\xFF\xFF\xFF" |
||||
|
HEADER_MULTI = b"\xFE\xFF\xFF\xFF" |
||||
|
|
||||
|
class A2SFragment: |
||||
|
def __init__(self, message_id, fragment_count, fragment_id, mtu, |
||||
|
decompressed_size=0, crc=0, payload=b""): |
||||
|
self.message_id = message_id |
||||
|
self.fragment_count = fragment_count |
||||
|
self.fragment_id = fragment_id |
||||
|
self.mtu = mtu |
||||
|
self.decompressed_size = decompressed_size |
||||
|
self.crc = crc |
||||
|
self.payload = payload |
||||
|
|
||||
|
@property |
||||
|
def is_compressed(self): |
||||
|
return bool(self.message_id & (1 << 15)) |
||||
|
|
||||
|
def decode_fragment(data): |
||||
|
reader = ByteReader( |
||||
|
io.BytesIO(data), endian="<", encoding=default_encoding) |
||||
|
frag = A2SFragment( |
||||
|
message_id=reader.read_uint32(), |
||||
|
fragment_count=reader.read_uint8(), |
||||
|
fragment_id=reader.read_uint8(), |
||||
|
mtu=reader.read_uint16() |
||||
|
) |
||||
|
if frag.is_compressed: |
||||
|
frag.decompressed_size = reader.read_uint32() |
||||
|
frag.crc = reader.read_uint32() |
||||
|
frag.payload = bz2.decompress(reader.read()) |
||||
|
else: |
||||
|
frag.payload = reader.read() |
||||
|
|
||||
|
return frag |
||||
|
|
||||
|
class A2SStream: |
||||
|
def __init__(self, address, timeout): |
||||
|
self.address = address |
||||
|
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
||||
|
self._socket.settimeout(timeout) |
||||
|
|
||||
|
def __del__(self): |
||||
|
self.close() |
||||
|
|
||||
|
def send(self, data): |
||||
|
packet = HEADER_SIMPLE + data |
||||
|
self._socket.sendto(packet, self.address) |
||||
|
|
||||
|
def recv(self): |
||||
|
packet = self._socket.recv(4096) |
||||
|
header = packet[:4] |
||||
|
data = packet[4:] |
||||
|
if header == HEADER_SIMPLE: |
||||
|
return data |
||||
|
elif header == HEADER_MULTI: |
||||
|
fragments = [decode_fragment(data)] |
||||
|
while len(fragments) < fragments[0].fragment_count: |
||||
|
packet = self._socket.recv(4096) |
||||
|
fragments.append(decode_fragment(packet[4:])) |
||||
|
fragments.sort(key=lambda f: f.fragment_id) |
||||
|
return b"".join(fragment.payload for fragment in fragments) |
||||
|
else: |
||||
|
raise BrokenMessageError( |
||||
|
"Invalid packet header: " + repr(header)) |
||||
|
|
||||
|
def close(self): |
||||
|
self._socket.close() |
||||
|
|
||||
|
def request(address, data, timeout): |
||||
|
stream = A2SStream(address, timeout) |
||||
|
stream.send(data) |
||||
|
resp = stream.recv() |
||||
|
stream.close() |
||||
|
return resp |
@ -0,0 +1,152 @@ |
|||||
|
import struct |
||||
|
import io |
||||
|
|
||||
|
from a2s.exceptions import BufferExhaustedError |
||||
|
|
||||
|
|
||||
|
|
||||
|
class ByteReader(): |
||||
|
def __init__(self, stream, endian="=", encoding=None): |
||||
|
self.stream = stream |
||||
|
self.endian = endian |
||||
|
self.encoding = encoding |
||||
|
|
||||
|
def read(self, size=-1): |
||||
|
data = self.stream.read(size) |
||||
|
if size > -1 and len(data) != size: |
||||
|
raise BufferExhaustedError() |
||||
|
|
||||
|
return data |
||||
|
|
||||
|
def peek(self, size=-1): |
||||
|
cur_pos = self.stream.tell() |
||||
|
data = self.stream.read(size) |
||||
|
self.stream.seek(cur_pos, io.SEEK_SET) |
||||
|
return data |
||||
|
|
||||
|
def unpack(self, fmt): |
||||
|
fmt = self.endian + fmt |
||||
|
fmt_size = struct.calcsize(fmt) |
||||
|
return struct.unpack(fmt, self.read(fmt_size)) |
||||
|
|
||||
|
def unpack_one(self, fmt): |
||||
|
values = self.unpack(fmt) |
||||
|
assert len(values) == 1 |
||||
|
return values[0] |
||||
|
|
||||
|
def read_int8(self): |
||||
|
return self.unpack_one("b") |
||||
|
|
||||
|
def read_uint8(self): |
||||
|
return self.unpack_one("B") |
||||
|
|
||||
|
def read_int16(self): |
||||
|
return self.unpack_one("h") |
||||
|
|
||||
|
def read_uint16(self): |
||||
|
return self.unpack_one("H") |
||||
|
|
||||
|
def read_int32(self): |
||||
|
return self.unpack_one("l") |
||||
|
|
||||
|
def read_uint32(self): |
||||
|
return self.unpack_one("L") |
||||
|
|
||||
|
def read_int64(self): |
||||
|
return self.unpack_one("q") |
||||
|
|
||||
|
def read_uint64(self): |
||||
|
return self.unpack_one("Q") |
||||
|
|
||||
|
def read_float(self): |
||||
|
return self.unpack_one("f") |
||||
|
|
||||
|
def read_double(self): |
||||
|
return self.unpack_one("d") |
||||
|
|
||||
|
def read_bool(self): |
||||
|
return bool(self.unpack("b")) |
||||
|
|
||||
|
def read_char(self): |
||||
|
char = self.unpack_one("c") |
||||
|
if self.encoding is not None: |
||||
|
return char.decode(self.encoding) |
||||
|
else: |
||||
|
return char |
||||
|
|
||||
|
def read_cstring(self, charsize=1): |
||||
|
string = b"" |
||||
|
while True: |
||||
|
c = self.read(charsize) |
||||
|
if int.from_bytes(c, "little") == 0: |
||||
|
break |
||||
|
else: |
||||
|
string += c |
||||
|
|
||||
|
if self.encoding is not None: |
||||
|
return string.decode(self.encoding) |
||||
|
else: |
||||
|
return string |
||||
|
|
||||
|
def read_ip(self): |
||||
|
return ".".join(str(o) for o in self.unpack("BBBB")) |
||||
|
|
||||
|
|
||||
|
class ByteWriter(): |
||||
|
def __init__(self, stream, endian="=", encoding=None): |
||||
|
self.stream = stream |
||||
|
self.endian = endian |
||||
|
self.encoding = encoding |
||||
|
|
||||
|
def write(self, *args): |
||||
|
return self.stream.write(*args) |
||||
|
|
||||
|
def pack(self, fmt, *values): |
||||
|
fmt = self.endian + fmt |
||||
|
fmt_size = struct.calcsize(fmt) |
||||
|
return self.stream.write(struct.pack(fmt, *values)) |
||||
|
|
||||
|
def write_int8(self, val): |
||||
|
self.pack("b", val) |
||||
|
|
||||
|
def write_uint8(self, val): |
||||
|
self.pack("B", val) |
||||
|
|
||||
|
def write_int16(self, val): |
||||
|
self.pack("h", val) |
||||
|
|
||||
|
def write_uint16(self, val): |
||||
|
self.pack("H", val) |
||||
|
|
||||
|
def write_int32(self, val): |
||||
|
self.pack("l", val) |
||||
|
|
||||
|
def write_uint32(self, val): |
||||
|
self.pack("L", val) |
||||
|
|
||||
|
def write_int64(self, val): |
||||
|
self.pack("q", val) |
||||
|
|
||||
|
def write_uint64(self, val): |
||||
|
self.pack("Q", val) |
||||
|
|
||||
|
def write_float(self, val): |
||||
|
self.pack("f", val) |
||||
|
|
||||
|
def write_double(self, val): |
||||
|
self.pack("d", val) |
||||
|
|
||||
|
def write_bool(self, val): |
||||
|
self.pack("b", val) |
||||
|
|
||||
|
def write_char(self, val): |
||||
|
if self.encoding is not None: |
||||
|
self.pack("c", val.encode(self.encoding)) |
||||
|
else: |
||||
|
self.pack("c", val) |
||||
|
|
||||
|
def write_cstring(self, val): |
||||
|
if self.encoding is not None: |
||||
|
self.write(val.encode(self.encoding) + b"\x00") |
||||
|
else: |
||||
|
self.write(val + b"\x00") |
@ -0,0 +1,16 @@ |
|||||
|
default_timeout = 3.0 |
||||
|
default_encoding = "utf-8" |
||||
|
|
||||
|
def set_default_timeout(timeout): |
||||
|
"""Set module-wide default timeout in seconds""" |
||||
|
default_timeout = timeout |
||||
|
|
||||
|
def get_default_timeout(): |
||||
|
"""Get module-wide default timeout in seconds""" |
||||
|
return default_timeout |
||||
|
|
||||
|
def set_default_encoding(enc): |
||||
|
default_encoding = enc |
||||
|
|
||||
|
def get_default_encoding(): |
||||
|
return default_encoding |
@ -0,0 +1,5 @@ |
|||||
|
class BrokenMessageError(Exception): |
||||
|
pass |
||||
|
|
||||
|
class BufferExhaustedError(BrokenMessageError): |
||||
|
pass |
@ -0,0 +1,112 @@ |
|||||
|
import time |
||||
|
import io |
||||
|
|
||||
|
from a2s.exceptions import BrokenMessageError |
||||
|
from a2s.defaults import default_encoding, default_timeout |
||||
|
from a2s.a2sstream import request |
||||
|
from a2s.byteio import ByteReader |
||||
|
|
||||
|
|
||||
|
|
||||
|
A2S_INFO_RESPONSE = 0x49 |
||||
|
|
||||
|
class InfoResponse: |
||||
|
def __init__(self, protocol, server_name, map_name, folder, game, |
||||
|
app_id, player_count, max_players, bot_count, |
||||
|
server_type, platform, password_protected, vac_enabled, |
||||
|
version, edf=0, port=0, steam_id=0, stv_port=0, |
||||
|
stv_name="", keywords="", game_id=0): |
||||
|
self.protocol = protocol |
||||
|
self.server_name = server_name |
||||
|
self.map_name = map_name |
||||
|
self.folder = folder |
||||
|
self.game = game |
||||
|
self.app_id = app_id |
||||
|
self.player_count = player_count |
||||
|
self.max_players = max_players |
||||
|
self.bot_count = bot_count |
||||
|
self.server_type = server_type.lower() |
||||
|
self.platform = platform.lower() |
||||
|
if self.platform == "o": |
||||
|
self.platform = "m" |
||||
|
self.password_protected = password_protected |
||||
|
self.vac_enabled = vac_enabled |
||||
|
self.version = version |
||||
|
|
||||
|
self.edf = edf |
||||
|
self.port = port |
||||
|
self.steam_id = steam_id |
||||
|
self.stv_port = stv_port |
||||
|
self.stv_name = stv_name |
||||
|
self.keywords = keywords |
||||
|
self.game_id = game_id |
||||
|
|
||||
|
@property |
||||
|
def has_port(self): |
||||
|
return bool(self.edf & 0x80) |
||||
|
|
||||
|
@property |
||||
|
def has_steam_id(self): |
||||
|
return bool(self.edf & 0x10) |
||||
|
|
||||
|
@property |
||||
|
def has_stv(self): |
||||
|
return bool(self.edf & 0x40) |
||||
|
|
||||
|
@property |
||||
|
def has_keywords(self): |
||||
|
return bool(self.edf & 0x20) |
||||
|
|
||||
|
@property |
||||
|
def has_game_id(self): |
||||
|
return bool(self.edf & 0x01) |
||||
|
|
||||
|
|
||||
|
def info(address, timeout=default_timeout): |
||||
|
send_time = time.monotonic() |
||||
|
resp_data = request(address, b"\x54Source Engine Query\0", timeout) |
||||
|
recv_time = time.monotonic() |
||||
|
reader = ByteReader( |
||||
|
io.BytesIO(resp_data), endian="<", encoding=default_encoding) |
||||
|
|
||||
|
response_type = reader.read_uint8() |
||||
|
if response_type != A2S_INFO_RESPONSE: |
||||
|
raise BrokenMessageError( |
||||
|
"Invalid response type: " + str(response_type)) |
||||
|
|
||||
|
resp = InfoResponse( |
||||
|
protocol=reader.read_uint8(), |
||||
|
server_name=reader.read_cstring(), |
||||
|
map_name=reader.read_cstring(), |
||||
|
folder=reader.read_cstring(), |
||||
|
game=reader.read_cstring(), |
||||
|
app_id=reader.read_uint16(), |
||||
|
player_count=reader.read_uint8(), |
||||
|
max_players=reader.read_uint8(), |
||||
|
bot_count=reader.read_uint8(), |
||||
|
server_type=reader.read_char(), |
||||
|
platform=reader.read_char(), |
||||
|
password_protected=reader.read_bool(), |
||||
|
vac_enabled=reader.read_bool(), |
||||
|
version=reader.read_cstring() |
||||
|
) |
||||
|
resp.ping = recv_time - send_time |
||||
|
|
||||
|
try: |
||||
|
resp.edf = reader.read_uint8() |
||||
|
except BufferExhaustedError: |
||||
|
pass |
||||
|
|
||||
|
if resp.has_port: |
||||
|
resp.port = reader.read_uint16() |
||||
|
if resp.has_steam_id: |
||||
|
resp.steam_id = reader.read_uint64() |
||||
|
if resp.has_stv: |
||||
|
resp.stv_port = reader.read_uint16() |
||||
|
resp.stv_name = reader.read_cstring() |
||||
|
if resp.has_keywords: |
||||
|
resp.keywords = reader.read_cstring() |
||||
|
if resp.has_game_id: |
||||
|
resp.game_id = reader.read_uint64() |
||||
|
|
||||
|
return resp |
@ -0,0 +1,54 @@ |
|||||
|
import io |
||||
|
|
||||
|
from a2s.exceptions import BrokenMessageError |
||||
|
from a2s.defaults import default_encoding, default_timeout |
||||
|
from a2s.a2sstream import request |
||||
|
from a2s.byteio import ByteReader |
||||
|
|
||||
|
|
||||
|
|
||||
|
A2S_PLAYER_RESPONSE = 0x44 |
||||
|
A2S_CHALLENGE_RESPONSE = 0x41 |
||||
|
|
||||
|
class PlayerEntry: |
||||
|
def __init__(self, index, name, score, duration): |
||||
|
self.index = index |
||||
|
self.name = name |
||||
|
self.score = score |
||||
|
self.duration = duration |
||||
|
|
||||
|
class PlayersResponse: |
||||
|
def __init__(self, player_count, players): |
||||
|
self.player_count = player_count |
||||
|
self.players = players |
||||
|
|
||||
|
def players(address, challenge=0, timeout=default_timeout): |
||||
|
resp_data = request( |
||||
|
address, b"\x55" + challenge.to_bytes(4, "little"), timeout) |
||||
|
reader = ByteReader( |
||||
|
io.BytesIO(resp_data), endian="<", encoding=default_encoding) |
||||
|
|
||||
|
response_type = reader.read_uint8() |
||||
|
if response_type == A2S_CHALLENGE_RESPONSE: |
||||
|
challenge = reader.read_int32() |
||||
|
return players(address, challenge, timeout) |
||||
|
|
||||
|
if response_type != A2S_PLAYER_RESPONSE: |
||||
|
raise BrokenMessageError( |
||||
|
"Invalid response type: " + str(response_type)) |
||||
|
|
||||
|
player_count = reader.read_uint8() |
||||
|
resp = PlayersResponse( |
||||
|
player_count=player_count, |
||||
|
players=[ |
||||
|
PlayerEntry( |
||||
|
index=reader.read_uint8(), |
||||
|
name=reader.read_cstring(), |
||||
|
score=reader.read_int32(), |
||||
|
duration=reader.read_float() |
||||
|
) |
||||
|
for player_num in range(player_count) |
||||
|
] |
||||
|
) |
||||
|
|
||||
|
return resp |
@ -0,0 +1,53 @@ |
|||||
|
import io |
||||
|
|
||||
|
from a2s.exceptions import BrokenMessageError |
||||
|
from a2s.defaults import default_encoding, default_timeout |
||||
|
from a2s.a2sstream import request |
||||
|
from a2s.byteio import ByteReader |
||||
|
|
||||
|
|
||||
|
|
||||
|
A2S_RULES_RESPONSE = 0x45 |
||||
|
A2S_CHALLENGE_RESPONSE = 0x41 |
||||
|
|
||||
|
class RulesResponse: |
||||
|
def __init__(self, rule_count, rules): |
||||
|
self.rule_count = rule_count |
||||
|
self.rules = rules |
||||
|
|
||||
|
def rules(address, challenge=0, timeout=default_timeout): |
||||
|
resp_data = request( |
||||
|
address, b"\x56" + challenge.to_bytes(4, "little"), timeout) |
||||
|
reader = ByteReader( |
||||
|
io.BytesIO(resp_data), endian="<", encoding=default_encoding) |
||||
|
|
||||
|
# A2S_RESPONSE misteriously seems to add a FF FF FF FF |
||||
|
# long to the beginning of the response which isn't |
||||
|
# mentioned on the wiki. |
||||
|
# |
||||
|
# Behaviour witnessed with TF2 server 94.23.226.200:2045 |
||||
|
# As of 2015-11-22, Quake Live servers on steam do not |
||||
|
# Source: valve-python messages.py |
||||
|
if reader.peek(4) == b"\xFF\xFF\xFF\xFF": |
||||
|
reader.read(4) |
||||
|
|
||||
|
response_type = reader.read_uint8() |
||||
|
if response_type == A2S_CHALLENGE_RESPONSE: |
||||
|
challenge = reader.read_int32() |
||||
|
return rules(address, challenge, timeout) |
||||
|
|
||||
|
if response_type != A2S_RULES_RESPONSE: |
||||
|
raise BrokenMessageError( |
||||
|
"Invalid response type: " + str(response_type)) |
||||
|
|
||||
|
rule_count = reader.read_int16() |
||||
|
resp = RulesResponse( |
||||
|
rule_count=rule_count, |
||||
|
rules={ |
||||
|
reader.read_cstring(): reader.read_cstring() |
||||
|
for rule_num in range(rule_count) |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
return resp |
||||
|
|
Loading…
Reference in new issue