From 4793aba283ebfa9426f177ae6273d4e5ad254023 Mon Sep 17 00:00:00 2001 From: Zewsic Date: Thu, 15 May 2025 12:07:11 +0300 Subject: [PATCH 1/4] - added encryption / compression support --- handle-all.py | 32 ++++++++ sfs2x/transport/base.py | 6 +- sfs2x/transport/factory.py | 8 +- sfs2x/transport/tcp.py | 11 +-- tests/test_payload.py | 95 ---------------------- tests/test_protocol.py | 160 +++++++++++++++++++++---------------- tests/test_transport.py | 73 +++++++++++++++++ 7 files changed, 210 insertions(+), 175 deletions(-) create mode 100644 handle-all.py delete mode 100644 tests/test_payload.py create mode 100644 tests/test_transport.py diff --git a/handle-all.py b/handle-all.py new file mode 100644 index 0000000..db58236 --- /dev/null +++ b/handle-all.py @@ -0,0 +1,32 @@ +import os + + +def gather_py_files(root_dir): + py_files = [] + for dirpath, _, filenames in os.walk(root_dir): + for fname in filenames: + if fname.endswith(".py"): + full_path = os.path.join(dirpath, fname) + rel_path = os.path.relpath(full_path, root_dir) + py_files.append((rel_path, full_path)) + return py_files + + +def combine_py_files(root_dir, output_file): + py_files = gather_py_files(root_dir) + with open(output_file, "w", encoding="utf-8") as out_f: + for rel_path, full_path in py_files: + out_f.write(f"# === File: {rel_path} ===\n") + with open(full_path, encoding="utf-8") as in_f: + out_f.write(in_f.read()) + out_f.write("\n\n") + print(f"Combined {len(py_files)} files into {output_file}") + + +if __name__ == "__main__": + import sys + + # Usage: python combine_py_files.py [source_dir] [output_file] + source_dir = sys.argv[1] if len(sys.argv) > 1 else "." + output_file = sys.argv[2] if len(sys.argv) > 2 else "combined_output.py" + combine_py_files(source_dir, output_file) diff --git a/sfs2x/transport/base.py b/sfs2x/transport/base.py index f7e5e41..9a7fdb7 100644 --- a/sfs2x/transport/base.py +++ b/sfs2x/transport/base.py @@ -11,7 +11,9 @@ class Transport(ABC): _closed: bool - def __init__(self) -> None: + def __init__(self, *, compress_threshold: int | None = None, encryption_key: bytes | None = None) -> None: + self.compress_threshold = compress_threshold + self.encryption_key = encryption_key self._closed = True async def open(self) -> "Transport": @@ -30,7 +32,7 @@ async def recv(self) -> Message: msg = "Connection closed by remote host" raise ConnectionError(msg) raw = await self._recv_raw() - return decode(Buffer(raw)) + return decode(Buffer(raw), encryption_key=self.encryption_key) async def close(self) -> None: if not self._closed: diff --git a/sfs2x/transport/factory.py b/sfs2x/transport/factory.py index 66493df..69a77bf 100644 --- a/sfs2x/transport/factory.py +++ b/sfs2x/transport/factory.py @@ -3,7 +3,7 @@ from sfs2x.transport import Acceptor, TCPAcceptor, TCPTransport, Transport -def client_from_url(url: str) -> Transport: +def client_from_url(url: str, *, compress_threshold: int | None = None, encryption_key: bytes | None = None) -> Transport: """ Create transport from url. @@ -16,11 +16,11 @@ def client_from_url(url: str) -> Transport: if scheme == "tcp": port = u.port or 9933 - return TCPTransport(u.hostname or "localhost", port) + return TCPTransport(u.hostname or "localhost", port, compress_threshold=compress_threshold, encryption_key=encryption_key) raise NotImplementedError -def server_from_url(url: str) -> TCPAcceptor | Acceptor: +def server_from_url(url: str, compress_threshold: int | None = None, encryption_key: bytes | None = None) -> TCPAcceptor | Acceptor: """ Create acceptor from url. @@ -33,5 +33,5 @@ def server_from_url(url: str) -> TCPAcceptor | Acceptor: if scheme == "tcp": port = u.port or 9933 - return TCPAcceptor(u.hostname or "localhost", port) + return TCPAcceptor(u.hostname or "localhost", port, compress_threshold=compress_threshold, encryption_key=encryption_key) raise NotImplementedError diff --git a/sfs2x/transport/tcp.py b/sfs2x/transport/tcp.py index 1e0986f..9a82b16 100644 --- a/sfs2x/transport/tcp.py +++ b/sfs2x/transport/tcp.py @@ -12,8 +12,8 @@ class TCPTransport(Transport): """SmartFox Transport realisation with Async Streams.""" - def __init__(self, host: str, port: int) -> None: - super().__init__() + def __init__(self, host: str, port: int, compress_threshold: int | None = None, encryption_key: bytes | None = None) -> None: + super().__init__(compress_threshold=compress_threshold, encryption_key=encryption_key) self._host = host self._port = port self._reader: StreamReader | None = None @@ -62,7 +62,6 @@ async def _recv_raw(self) -> bytes: msg = "Connection closed by remote host" raise ConnectionError(msg) from e - logger.info("Received %s bytes from %s:%s", length, self._host, self._port) return _flags + len_bytes + body @@ -77,11 +76,13 @@ async def _close_impl(self) -> None: class TCPAcceptor(Acceptor): """Server-Side implementation of the TCP Acceptor.""" - def __init__(self, host: str, port: int) -> None: + def __init__(self, host: str, port: int, compress_threshold: int | None = None, encryption_key: bytes | None = None) -> None: super().__init__() self._host = host self._port = port self._server: AbstractServer | None = None + self._compress_threshold = compress_threshold + self._encryption_key = encryption_key async def __aiter__(self) -> AsyncIterator[Transport]: # type: ignore # noqa: PGH003 """Iterate all new connections.""" @@ -106,7 +107,7 @@ async def producer() -> None: async def _on_conn(self, reader: StreamReader, writer: StreamWriter) -> None: host, port = writer.get_extra_info("peername") logger.info("Connection from %s:%s", host, port) - transport = TCPTransport(host, port) + transport = TCPTransport(host, port, compress_threshold=self._compress_threshold, encryption_key=self._encryption_key) transport._reader = reader # noqa: SLF001 transport._writer = writer # noqa: SLF001 transport._closed = False # noqa: SLF001 diff --git a/tests/test_payload.py b/tests/test_payload.py deleted file mode 100644 index 30026f6..0000000 --- a/tests/test_payload.py +++ /dev/null @@ -1,95 +0,0 @@ -import pytest - -from sfs2x.core import UtfStringArray, Int -from sfs2x.core.buffer import Buffer -from sfs2x.core.types.containers import SFSObject -from sfs2x.protocol import ( - Message, - ControllerID, - SysAction, - encode, - decode, - Flag, -) - - -def make_payload(**fields): - """Make simple SFSObject from key-value pairs.""" - obj = SFSObject() - for k, v in fields.items(): - obj.put_text(k, v) - return obj - - -@pytest.mark.parametrize( - "controller,action,payload", - [ - (ControllerID.SYSTEM, SysAction.LOGIN, make_payload(un="neo")), - (ControllerID.EXTENSION, 0, make_payload(c="ping")), - ], -) -def test_encode_decode_roundtrip(controller: int, action, payload): - msg_out = Message(controller, action, payload) - raw = encode(msg_out) - msg_in = decode(Buffer(raw)) - assert msg_in == msg_out - - -def test_expected_short_bytes(): - msg = Message( - ControllerID.SYSTEM, - SysAction.PING_PONG, - make_payload(txt="hello"), - ) - - expected_bytes = encode(msg) - assert encode(msg) == expected_bytes - assert decode(Buffer(expected_bytes)) == msg - - -def test_long_packet(): - big_string = "x" * 70000 # 70 000 > 65535 - msg = Message( - ControllerID.SYSTEM, - SysAction.HANDSHAKE, - make_payload(blob=big_string), - ) - raw = encode(msg, compress_threshold=None) - - first_flag = Flag(raw[0]) - assert first_flag & Flag.BINARY - assert first_flag & Flag.BIG_SIZE - - decoded = decode(Buffer(raw)) - assert decoded.payload.get("blob") == big_string - -def test_encrypted_and_compressed_long_packet(): - big_string = "x" * 70000 - msg = Message( - ControllerID.SYSTEM, - SysAction.HANDSHAKE, - make_payload(blob=big_string), - ) - raw = encode(msg, compress_threshold=0, encryption_key=b'1234567890123456') - - first_flag = Flag(raw[0]) - assert first_flag & Flag.BINARY - assert first_flag & Flag.ENCRYPTED - assert first_flag & Flag.COMPRESSED - - decoded = decode(Buffer(raw), encryption_key=b'1234567890123456') - assert decoded.payload.get("blob") == big_string - - -def test_unpack_binary_packet(): - binary_message = b'\x80\x00T\x12\x00\x03\x00\x01c\x02\x01\x00\x01a\x03\x00\x0c\x00\x01p\x12\x00\x03\x00\x01c\x08\x00\x0ctest_command\x00\x01r\x04\xff\xff\xff\xff\x00\x01p\x12\x00\x02\x00\x03num\x04\xff\xff\xff\xff\x00\x07strings\x10\x00\x02\x00\x02hi\x00\x04mega' - decoded = decode(binary_message) - - re_encoded = Message.extension("test_command", SFSObject({ - "num": Int(-1), - "strings": UtfStringArray(['hi', 'mega']) - })) - - assert decoded.controller == ControllerID.EXTENSION - assert decoded.action == 12 - assert decoded == re_encoded diff --git a/tests/test_protocol.py b/tests/test_protocol.py index d791c08..30026f6 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,73 +1,95 @@ -import asyncio import pytest -import pytest_asyncio -from sfs2x.core import Float, UtfString, Int, Double -from sfs2x.transport import client_from_url, server_from_url, TCPTransport -from sfs2x.protocol import Message, ControllerID, SysAction +from sfs2x.core import UtfStringArray, Int +from sfs2x.core.buffer import Buffer from sfs2x.core.types.containers import SFSObject +from sfs2x.protocol import ( + Message, + ControllerID, + SysAction, + encode, + decode, + Flag, +) -@pytest_asyncio.fixture -async def echo_server(event_loop): - server_task = event_loop.create_task(run_echo_server()) - await asyncio.sleep(0.2) - - yield - - server_task.cancel() - with pytest.raises(asyncio.CancelledError): - await server_task - -async def run_echo_server(): - async for conn in server_from_url("tcp://0.0.0.0:9000"): - asyncio.create_task(some_handler(conn)) - -async def some_handler(conn: TCPTransport): - try: - while True: - msg = await conn.recv() - obj = msg.payload.value.get('input') - obj.value *= 2 - msg.payload['resp'] = obj - await conn.send(msg) - except ConnectionError: - await conn.close() - -@pytest.mark.asyncio -async def test_tcp_echo_roundtrip(echo_server): - conn = await client_from_url("tcp://localhost:9000").open() - for value in [UtfString('Friday, '), Int(8), Double(123.12)]: - test_msg = Message(ControllerID.SYSTEM, SysAction.PING_PONG, SFSObject({'input': value})) - await conn.send(test_msg) - - answer = await conn.recv() - assert answer.controller == test_msg.controller - assert answer.action == test_msg.action - assert answer.payload.get('resp') == value.value * 2 - await conn.close() - -@pytest.mark.asyncio -async def test_msm_server(): - conn = await client_from_url("tcp://107.20.67.227").open() - - session_info = SFSObject() - session_info.put_utf_string("api", "1.0.3") - session_info.put_utf_string("cl", "UnityPlayer::") - session_info.put_bool("bin", True) - - await conn.send(Message(ControllerID.SYSTEM, SysAction.HANDSHAKE, session_info)) - - handshake = await conn.recv() - assert handshake.controller == ControllerID.SYSTEM - assert handshake.action == SysAction.HANDSHAKE - - auth_info = SFSObject() - auth_info.put_utf_string("zn", "MySingingPenis") - auth_info.put_utf_string("un", "") - auth_info.put_utf_string("pw", "") - auth_info.put_sfs_object("p", SFSObject()) - - await conn.send(Message(ControllerID.SYSTEM, SysAction.LOGIN, auth_info)) - - resp = await conn.recv() - assert resp.payload['ec'] == 1 \ No newline at end of file + +def make_payload(**fields): + """Make simple SFSObject from key-value pairs.""" + obj = SFSObject() + for k, v in fields.items(): + obj.put_text(k, v) + return obj + + +@pytest.mark.parametrize( + "controller,action,payload", + [ + (ControllerID.SYSTEM, SysAction.LOGIN, make_payload(un="neo")), + (ControllerID.EXTENSION, 0, make_payload(c="ping")), + ], +) +def test_encode_decode_roundtrip(controller: int, action, payload): + msg_out = Message(controller, action, payload) + raw = encode(msg_out) + msg_in = decode(Buffer(raw)) + assert msg_in == msg_out + + +def test_expected_short_bytes(): + msg = Message( + ControllerID.SYSTEM, + SysAction.PING_PONG, + make_payload(txt="hello"), + ) + + expected_bytes = encode(msg) + assert encode(msg) == expected_bytes + assert decode(Buffer(expected_bytes)) == msg + + +def test_long_packet(): + big_string = "x" * 70000 # 70 000 > 65535 + msg = Message( + ControllerID.SYSTEM, + SysAction.HANDSHAKE, + make_payload(blob=big_string), + ) + raw = encode(msg, compress_threshold=None) + + first_flag = Flag(raw[0]) + assert first_flag & Flag.BINARY + assert first_flag & Flag.BIG_SIZE + + decoded = decode(Buffer(raw)) + assert decoded.payload.get("blob") == big_string + +def test_encrypted_and_compressed_long_packet(): + big_string = "x" * 70000 + msg = Message( + ControllerID.SYSTEM, + SysAction.HANDSHAKE, + make_payload(blob=big_string), + ) + raw = encode(msg, compress_threshold=0, encryption_key=b'1234567890123456') + + first_flag = Flag(raw[0]) + assert first_flag & Flag.BINARY + assert first_flag & Flag.ENCRYPTED + assert first_flag & Flag.COMPRESSED + + decoded = decode(Buffer(raw), encryption_key=b'1234567890123456') + assert decoded.payload.get("blob") == big_string + + +def test_unpack_binary_packet(): + binary_message = b'\x80\x00T\x12\x00\x03\x00\x01c\x02\x01\x00\x01a\x03\x00\x0c\x00\x01p\x12\x00\x03\x00\x01c\x08\x00\x0ctest_command\x00\x01r\x04\xff\xff\xff\xff\x00\x01p\x12\x00\x02\x00\x03num\x04\xff\xff\xff\xff\x00\x07strings\x10\x00\x02\x00\x02hi\x00\x04mega' + decoded = decode(binary_message) + + re_encoded = Message.extension("test_command", SFSObject({ + "num": Int(-1), + "strings": UtfStringArray(['hi', 'mega']) + })) + + assert decoded.controller == ControllerID.EXTENSION + assert decoded.action == 12 + assert decoded == re_encoded diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..cfa08b1 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,73 @@ +import asyncio +import pytest +import pytest_asyncio + +from sfs2x.core import Float, UtfString, Int, Double +from sfs2x.transport import client_from_url, server_from_url, TCPTransport +from sfs2x.protocol import Message, ControllerID, SysAction +from sfs2x.core.types.containers import SFSObject + +@pytest_asyncio.fixture +async def echo_server(event_loop): + server_task = event_loop.create_task(run_echo_server()) + await asyncio.sleep(0.2) + + yield + + server_task.cancel() + with pytest.raises(asyncio.CancelledError): + await server_task + +async def run_echo_server(): + async for conn in server_from_url("tcp://0.0.0.0:9000", encryption_key=b'mega_secured_key'): + asyncio.create_task(some_handler(conn)) + +async def some_handler(conn: TCPTransport): + try: + while True: + msg = await conn.recv() + obj = msg.payload.value.get('input') + obj.value *= 2 + msg.payload['resp'] = obj + await conn.send(msg) + except ConnectionError: + await conn.close() + +@pytest.mark.asyncio +async def test_tcp_echo_roundtrip(echo_server): + conn = await client_from_url("tcp://localhost:9000", encryption_key=b'mega_secured_key').open() + for value in [UtfString('Friday, '), Int(8), Double(123.12)]: + test_msg = Message(ControllerID.SYSTEM, SysAction.PING_PONG, SFSObject({'input': value})) + await conn.send(test_msg) + + answer = await conn.recv() + assert answer.controller == test_msg.controller + assert answer.action == test_msg.action + assert answer.payload.get('resp') == value.value * 2 + await conn.close() + +@pytest.mark.asyncio +async def test_msm_server(): + conn = await client_from_url("tcp://107.20.67.227").open() + + session_info = SFSObject() + session_info.put_utf_string("api", "1.0.3") + session_info.put_utf_string("cl", "UnityPlayer::") + session_info.put_bool("bin", True) + + await conn.send(Message(ControllerID.SYSTEM, SysAction.HANDSHAKE, session_info)) + + handshake = await conn.recv() + assert handshake.controller == ControllerID.SYSTEM + assert handshake.action == SysAction.HANDSHAKE + + auth_info = SFSObject() + auth_info.put_utf_string("zn", "MySingingPenis") + auth_info.put_utf_string("un", "") + auth_info.put_utf_string("pw", "") + auth_info.put_sfs_object("p", SFSObject()) + + await conn.send(Message(ControllerID.SYSTEM, SysAction.LOGIN, auth_info)) + + resp = await conn.recv() + assert resp.payload['ec'] == 1 \ No newline at end of file From 6e5eb12f32f27bf49e93fb1128c5da0d20399f0c Mon Sep 17 00:00:00 2001 From: Zewsic Date: Thu, 15 May 2025 12:30:05 +0300 Subject: [PATCH 2/4] - fixed encryption --- sfs2x/protocol/codec.py | 6 +++++- sfs2x/transport/base.py | 10 +++++----- sfs2x/transport/tcp.py | 8 ++++++-- tests/test_transport.py | 1 + 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/sfs2x/protocol/codec.py b/sfs2x/protocol/codec.py index cedee75..4fa1ead 100644 --- a/sfs2x/protocol/codec.py +++ b/sfs2x/protocol/codec.py @@ -87,7 +87,11 @@ def decode(data, *, encryption_key: bytes | None = None) -> Message: msg = "Library pycryptodome is not installed. Install it before using encryption (pip install pycryptodome)." raise ImportError(msg) cipher = AESCipher(encryption_key) - payload_bytes = cipher.decrypt(payload_bytes) + try: + payload_bytes = cipher.decrypt(payload_bytes) + except ValueError as e: + msg = "Encryption error occurred." + raise ProtocolError(msg) from e if flags & Flag.COMPRESSED: payload_bytes = zlib.decompress(payload_bytes) diff --git a/sfs2x/transport/base.py b/sfs2x/transport/base.py index 9a7fdb7..1e50750 100644 --- a/sfs2x/transport/base.py +++ b/sfs2x/transport/base.py @@ -10,10 +10,10 @@ class Transport(ABC): """Abstract base class for transports.""" _closed: bool + _compress_threshold: int | None = None + _encryption_key: bytes | None = None - def __init__(self, *, compress_threshold: int | None = None, encryption_key: bytes | None = None) -> None: - self.compress_threshold = compress_threshold - self.encryption_key = encryption_key + def __init__(self) -> None: self._closed = True async def open(self) -> "Transport": @@ -25,14 +25,14 @@ async def send(self, msg: Message) -> None: if self._closed: err_msg = "Connection closed by remote host" raise ConnectionError(err_msg) - await self._send_raw(encode(msg)) + await self._send_raw(encode(msg, compress_threshold=self._compress_threshold, encryption_key=self._encryption_key)) async def recv(self) -> Message: if self._closed: msg = "Connection closed by remote host" raise ConnectionError(msg) raw = await self._recv_raw() - return decode(Buffer(raw), encryption_key=self.encryption_key) + return decode(Buffer(raw), encryption_key=self._encryption_key) async def close(self) -> None: if not self._closed: diff --git a/sfs2x/transport/tcp.py b/sfs2x/transport/tcp.py index 9a82b16..9da0190 100644 --- a/sfs2x/transport/tcp.py +++ b/sfs2x/transport/tcp.py @@ -13,11 +13,13 @@ class TCPTransport(Transport): """SmartFox Transport realisation with Async Streams.""" def __init__(self, host: str, port: int, compress_threshold: int | None = None, encryption_key: bytes | None = None) -> None: - super().__init__(compress_threshold=compress_threshold, encryption_key=encryption_key) + super().__init__() self._host = host self._port = port self._reader: StreamReader | None = None self._writer: StreamWriter | None = None + self._encryption_key = encryption_key + self._compress_threshold = compress_threshold @property def host(self) -> str: @@ -107,8 +109,10 @@ async def producer() -> None: async def _on_conn(self, reader: StreamReader, writer: StreamWriter) -> None: host, port = writer.get_extra_info("peername") logger.info("Connection from %s:%s", host, port) - transport = TCPTransport(host, port, compress_threshold=self._compress_threshold, encryption_key=self._encryption_key) + transport = TCPTransport(host, port) transport._reader = reader # noqa: SLF001 transport._writer = writer # noqa: SLF001 transport._closed = False # noqa: SLF001 + transport._encryption_key = self._encryption_key # noqa: SLF001 + transport._compress_threshold = self._compress_threshold # noqa: SLF001 await self._queue.put(transport) diff --git a/tests/test_transport.py b/tests/test_transport.py index cfa08b1..95756ee 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -26,6 +26,7 @@ async def some_handler(conn: TCPTransport): try: while True: msg = await conn.recv() + print(msg) obj = msg.payload.value.get('input') obj.value *= 2 msg.payload['resp'] = obj From 66022a243080c41b6d7cc0edc9908b6543100142 Mon Sep 17 00:00:00 2001 From: Zewsic Date: Thu, 15 May 2025 12:36:04 +0300 Subject: [PATCH 3/4] - add listen feature to transport --- sfs2x/transport/base.py | 11 ++++++++++- tests/test_transport.py | 16 ++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/sfs2x/transport/base.py b/sfs2x/transport/base.py index 1e50750..b4fc37a 100644 --- a/sfs2x/transport/base.py +++ b/sfs2x/transport/base.py @@ -25,7 +25,8 @@ async def send(self, msg: Message) -> None: if self._closed: err_msg = "Connection closed by remote host" raise ConnectionError(err_msg) - await self._send_raw(encode(msg, compress_threshold=self._compress_threshold, encryption_key=self._encryption_key)) + await self._send_raw( + encode(msg, compress_threshold=self._compress_threshold, encryption_key=self._encryption_key)) async def recv(self) -> Message: if self._closed: @@ -39,6 +40,14 @@ async def close(self) -> None: await self._close_impl() self._closed = True + async def listen(self) -> AsyncIterator[Message]: + """Async iterator over incoming messages.""" + while not self._closed: + try: + yield await self.recv() + except (ConnectionError, RuntimeError): + break + @abstractmethod async def _open(self) -> None: ... diff --git a/tests/test_transport.py b/tests/test_transport.py index 95756ee..b051a54 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -23,16 +23,12 @@ async def run_echo_server(): asyncio.create_task(some_handler(conn)) async def some_handler(conn: TCPTransport): - try: - while True: - msg = await conn.recv() - print(msg) - obj = msg.payload.value.get('input') - obj.value *= 2 - msg.payload['resp'] = obj - await conn.send(msg) - except ConnectionError: - await conn.close() + async for msg in conn.listen(): + print(msg) + obj = msg.payload.value.get('input') + obj.value *= 2 + msg.payload['resp'] = obj + await conn.send(msg) @pytest.mark.asyncio async def test_tcp_echo_roundtrip(echo_server): From 1f6d222b72fbb9c8b963c016e1c5588eae217783 Mon Sep 17 00:00:00 2001 From: Zewsic Date: Thu, 15 May 2025 14:02:52 +0300 Subject: [PATCH 4/4] - Update Readme --- readme.md | 284 +++++++++++++++++++++++++++++----------- requirements.txt | 0 sfs2x/protocol/codec.py | 3 +- sfs2x/transport/base.py | 9 ++ tests/test_transport.py | 57 ++++---- 5 files changed, 242 insertions(+), 111 deletions(-) create mode 100644 requirements.txt diff --git a/readme.md b/readme.md index 3b32d35..3b3240a 100644 --- a/readme.md +++ b/readme.md @@ -1,118 +1,242 @@ +from sfs2x.transport import TCPTransport + # ZewSFS -ZewSFS is a Python implementation of the SmartFoxServer2X protocol, providing both client and server-side functionality. The library is modular, allowing flexible integration into various projects. +**ZewSFS** is a Python-based implementation of the [SmartFoxServer 2X (SFS2X)](https://www.smartfoxserver.com/) +protocol, offering both client and server-side capabilities. This library provides fundamental data types, transport +abstractions (TCP/WebSocket in the future), message encoding/decoding, and extensibility for custom encryption and +compression. -## Modules +## Table of Contents -- **core**: Contains foundational components, including data types and serialization logic. -- **client**: Handles client-side protocol implementation (in development). -- **server**: Manages server-side protocol implementation (in development). -- **orm**: Provides ORM-style interaction with protocol packets (in development). +* [Features](#features) +* [Installation](#installation) -## Core Module +* [Quick Start](#quick-start) + * [Client Example](#client-example) + * [Server Example](#server-example) -The `core` module defines the essential data types and serialization mechanisms for the SmartFoxServer2X protocol. +* [Modules Overview](#modules-overview) + * [Core](#core) + * [Protocol](#protocol) + * [Transport](#transport) -### Supported Data Types +* [Usage Examples](#usage-examples) + * [Working with `SFSObject` and `SFSArray`](#working-with-sfsobject-and-sfsarray) + * [Serialization / Deserialization](#serialization--deserialization) + * [Encrypted or Compressed Packets](#encrypted-or-compressed-packets) -The following data types are implemented in the `core` module: +* [Development Status](#development-status) +* [Contributing](#contributing) +* [License](#license) -- `NULL` (0): Represents a null value. -- `BOOL` (1): A boolean value (`True` or `False`). -- `BYTE` (2): An 8-bit integer. -- `SHORT` (3): A 16-bit integer. -- `INT` (4): A 32-bit integer. -- `LONG` (5): A 64-bit integer. -- `FLOAT` (6): A 32-bit floating-point number. -- `DOUBLE` (7): A 64-bit floating-point number. -- `UTF_STRING` (8): A UTF-8 encoded string. -- `BOOL_ARRAY` (9): An array of boolean values. -- `BYTE_ARRAY` (10): An array of 8-bit integers. -- `SHORT_ARRAY` (11): An array of 16-bit integers. -- `INT_ARRAY` (12): An array of 32-bit integers. -- `LONG_ARRAY` (13): An array of 64-bit integers. -- `FLOAT_ARRAY` (14): An array of 32-bit floating-point numbers. -- `DOUBLE_ARRAY` (15): An array of 64-bit floating-point numbers. -- `UTF_STRING_ARRAY` (16): An array of UTF-8 encoded strings. -- `SFS_ARRAY` (17): A container for nested arrays. -- `SFS_OBJECT` (18): A container for key-value pairs. -- `CLASS` (19): Not implemented. -- `TEXT` (20): A text string. +--- -### Usage Examples +## Features -#### Imperative Usage +* **Core**: Rich, low-level data structures (e.g., `SFSObject`, `SFSArray`) mirroring SmartFoxServer object models. +* **Protocol**: Easy-to-use encoding/decoding functions to convert between raw bytes and high-level `Message` objects. +* **Transport**: Ready-made TCP server (via `TCPAcceptor`) and client (`TCPTransport`) for sending and receiving SFS2X + messages. +* **Encryption**: Optional AES-128-CBC support via [PyCryptodome](https://pypi.org/project/pycryptodome/). -The `SFSObject` and `SFSArray` classes provide a flexible, method-chaining API for building protocol-compliant data structures. +--- -```python -from sfs2x.core.types.containers import SFSObject, SFSArray +## Installation -# Create an SFSObject -object = SFSObject() +1. **Clone the Repository** -# Add a single integer -object.put_int('number', 12) + ```bash + git clone https://github.com/ZewMSM/ZewSFS.git + cd ZewSFS + ``` +2. **Install Dependencies** -# Chain multiple additions -object.put_double_array('doubles', [3.14, -4.5]) \ - .put_bool("flag", True) + ```bash + pip install -r requirements.txt + ``` -# Dictionary-like assignment with nested SFSArray -object['array'] = SFSArray().add_long(99999999999).add_bool(False) -``` + Make sure you have **Python 3.9+**. + If you plan to use **encrypted packets**, install PyCryptodome: + + ```bash + pip install pycryptodome + ``` + +--- + +## Quick Start + +> **Note**: These examples describe the server-client for the low-Level transport module. High-level server and client modules are currently under development. + +### Transport Client Example -#### Declarative Usage -For a more concise approach, you can define `SFSObject` and `SFSArray` structures using Python dictionaries and lists, leveraging specific type wrappers. ```python -from sfs2x.core import UtfString, UtfStringArray, Long -from sfs2x.core.types.containers import SFSObject, SFSArray - -# Create a nested SFSObject declaratively -object = SFSObject({ - 'nickname': UtfString('Zewsic'), - 'tags': UtfStringArray(['pro', 'player', 'admin']), - 'friends': SFSArray([ - SFSObject({ - 'id': Long(101), - 'nickname': UtfString('Cotulars'), - }), - SFSObject({ - 'id': Long(102), - 'nickname': UtfString('Tyrant'), - }), - ]) -}) +import asyncio +from sfs2x.transport.factory import client_from_url +from sfs2x.protocol import Message, ControllerID, SysAction +from sfs2x.core import SFSObject + + +async def run_client(): + async with client_from_url("tcp://localhost:9933") as client: + payload = SFSObject() + payload.put_utf_string("message", "Hello from ZewSFS client!") + + await client.send(Message( + controller=ControllerID.SYSTEM, + action=SysAction.PUBLIC_MESSAGE, + payload=payload + )) + + response = await client.recv() + print("Response:", response) + + +if __name__ == "__main__": + asyncio.run(run_client()) ``` -#### ORM Usage +### Server Example + +```python +import asyncio +from sfs2x.transport import server_from_url, TCPTransport +from sfs2x.protocol import Message, ControllerID, SysAction +from sfs2x.core import SFSObject + + +async def handle_client(client: TCPTransport): + async for message in client.listen(): + response_payload = SFSObject() + response_payload.put_utf_string("message", "Hello back from server!") + + await client.send(Message( + controller=ControllerID.SYSTEM, + action=SysAction.PUBLIC_MESSAGE, + payload=response_payload + )) + + +async def run_server(): + async for client in server_from_url("tcp://localhost:9933"): + print(f"New client connected: {client.host}:{client.port}") + asyncio.create_task(handle_client(client)) + + +if __name__ == "__main__": + asyncio.run(run_server()) +``` + +--- + +## Modules Overview + +### Core + +The `core` package provides fundamental data structures and serialization logic: -ORM-style interaction is currently in development and will provide a higher-level abstraction for working with protocol packets. +1. **Fields and Arrays**: + * `Bool`, `Byte`, `Short`, `Int`, `Long`, `Float`, `Double`, `UtfString`, `Text` + * `BoolArray`, `ByteArray`, `ShortArray`, `IntArray`, `LongArray`, `FloatArray`, `DoubleArray`, `UtfStringArray` + +2. **Containers**: + * `SFSObject` for key-value pairs + * `SFSArray` for sequential lists + +3. **Utility Classes**: + * `Buffer` for reading raw bytes + * `Field` as a base for packable items + * `registry`, `decode`, and `encode` for bridging raw bytes ↔ SFS data types -### Serialization +### Protocol -#### Serializing to Bytes +The `protocol` package focuses on reading/writing SFS2X-compliant packets: -Convert an `SFSObject` to its binary representation for transmission or storage. +* **`Message`**: High-level class representing a single SFS2X message with `controller`, `action`, and `payload`. +* **`Flag`**: Enum for packet flags (binary, compressed, encrypted, etc.). +* **`encode` / `decode`**: Convert `Message` ↔ binary packets, optionally using compression and AES encryption. +* **`AESCipher`**: AES-128-CBC encryption/decryption for securing packets (requires PyCryptodome). + +### Transport + +The `transport` package provides abstractions for client-server communication: + +* **`Transport` (abstract)**: Defines the required methods (`open`, `send`, `recv`, `close`) for any transport. +* **`TCPTransport`**: Client-side implementation using asyncio streams (TCP). +* **`TCPAcceptor`**: Server-side implementation using asyncio `start_server` (TCP). +* **`client_from_url` / `server_from_url`**: Factory methods to instantiate a transport from a URL (e.g., + `tcp://localhost:9933`). + +--- + +## Usage Examples + +### Working with `SFSObject` and `SFSArray` + +**Imperative style**: ```python -packed_object = object.to_bytes() +from sfs2x.core import SFSObject, SFSArray + +obj = SFSObject() +obj.put_int("score", 1200) +obj.put_double_array("history", [3.14, -4.5, 2.7]) \ + .put_bool("isAdmin", True) + +arr = SFSArray() +arr.add_utf_string("item1") +arr.add_utf_string("item2") + +obj["myArray"] = arr ``` -#### Deserializing from Bytes +**Declarative style**: + +```python +from sfs2x.core import UtfString, Int, SFSObject, SFSArray + +obj = SFSObject({ + "name": UtfString("Zewsic"), + "score": Int(2022), + "items": SFSArray([ + UtfString("Sword"), + UtfString("Shield"), + SFSObject({"key": UtfString("value")}) + ]) +}) +``` -Decode a byte string into an `SFSObject` using the `decode` function. +### Serialization / Deserialization ```python -from sfs2x.core import decode -object: SFSObject = decode(b'\x12\x00\x03\x00\x03num\x04\x00\x00\x00\x0c\x00\x03str\x08\x00\x05Hello') +from sfs2x.core import decode, SFSObject, Int + +# Serialize +obj = SFSObject({"example": Int(42)}) +raw_bytes = obj.to_bytes() + +# Deserialize +deserialized_obj: SFSObject = decode(raw_bytes) +print(deserialized_obj.get("example")) # 42 ``` -## Notes +### Encrypted or Compressed Packets -- The `client`, `server`, and `orm` modules are under active development and will be documented as they mature. -- The `CLASS` data type (19) is not implemented in the current version. +When creating or decoding messages, you can specify a threshold for compression and a key for encryption: -For further details or contributions, refer to the project's Git repository. +```python +from sfs2x.protocol import Message, encode, decode, SysAction, ControllerID +from sfs2x.core import SFSObject, UtfString + +encryption_key = b"my_secret_16byte" + +# Compress if payload > 512 bytes, encrypt with a 16-byte key +msg = Message(controller=ControllerID.EXTENSION, action=18, payload=SFSObject({"secret": UtfString("HideMe")})) +packet = encode(msg, compress_threshold=512, encryption_key=encryption_key) + +# Decoding +decoded_msg = decode(packet, encryption_key=encryption_key) +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/sfs2x/protocol/codec.py b/sfs2x/protocol/codec.py index 4fa1ead..98bc011 100644 --- a/sfs2x/protocol/codec.py +++ b/sfs2x/protocol/codec.py @@ -1,9 +1,8 @@ import zlib from typing import overload -from sfs2x.core import Buffer +from sfs2x.core import Buffer, SFSObject from sfs2x.core import decode as core_decode -from sfs2x.core.types.containers import SFSObject from sfs2x.protocol import AESCipher, Flag, Message, ProtocolError, UnsupportedFlagError _SHORT_MAX = 0xFFFF diff --git a/sfs2x/transport/base.py b/sfs2x/transport/base.py index b4fc37a..7c8b339 100644 --- a/sfs2x/transport/base.py +++ b/sfs2x/transport/base.py @@ -48,6 +48,15 @@ async def listen(self) -> AsyncIterator[Message]: except (ConnectionError, RuntimeError): break + async def __aenter__(self) -> "Transport": + """Async enter.""" + await self.open() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + """Async exit.""" + await self.close() + @abstractmethod async def _open(self) -> None: ... diff --git a/tests/test_transport.py b/tests/test_transport.py index b051a54..effc7fa 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -2,10 +2,9 @@ import pytest import pytest_asyncio -from sfs2x.core import Float, UtfString, Int, Double +from sfs2x.core import Float, UtfString, Int, Double, SFSObject from sfs2x.transport import client_from_url, server_from_url, TCPTransport from sfs2x.protocol import Message, ControllerID, SysAction -from sfs2x.core.types.containers import SFSObject @pytest_asyncio.fixture async def echo_server(event_loop): @@ -32,39 +31,39 @@ async def some_handler(conn: TCPTransport): @pytest.mark.asyncio async def test_tcp_echo_roundtrip(echo_server): - conn = await client_from_url("tcp://localhost:9000", encryption_key=b'mega_secured_key').open() - for value in [UtfString('Friday, '), Int(8), Double(123.12)]: - test_msg = Message(ControllerID.SYSTEM, SysAction.PING_PONG, SFSObject({'input': value})) - await conn.send(test_msg) + conn = client_from_url("tcp://localhost:9000", encryption_key=b'mega_secured_key') - answer = await conn.recv() - assert answer.controller == test_msg.controller - assert answer.action == test_msg.action - assert answer.payload.get('resp') == value.value * 2 - await conn.close() + async with conn: + for value in [UtfString('Friday, '), Int(8), Double(123.12)]: + test_msg = Message(ControllerID.SYSTEM, SysAction.PING_PONG, SFSObject({'input': value})) + await conn.send(test_msg) + + answer = await conn.recv() + assert answer.controller == test_msg.controller + assert answer.action == test_msg.action + assert answer.payload.get('resp') == value.value * 2 @pytest.mark.asyncio async def test_msm_server(): - conn = await client_from_url("tcp://107.20.67.227").open() - - session_info = SFSObject() - session_info.put_utf_string("api", "1.0.3") - session_info.put_utf_string("cl", "UnityPlayer::") - session_info.put_bool("bin", True) + async with client_from_url("tcp://107.20.67.227") as conn: + session_info = SFSObject() + session_info.put_utf_string("api", "1.0.3") + session_info.put_utf_string("cl", "UnityPlayer::") + session_info.put_bool("bin", True) - await conn.send(Message(ControllerID.SYSTEM, SysAction.HANDSHAKE, session_info)) + await conn.send(Message(ControllerID.SYSTEM, SysAction.HANDSHAKE, session_info)) - handshake = await conn.recv() - assert handshake.controller == ControllerID.SYSTEM - assert handshake.action == SysAction.HANDSHAKE + handshake = await conn.recv() + assert handshake.controller == ControllerID.SYSTEM + assert handshake.action == SysAction.HANDSHAKE - auth_info = SFSObject() - auth_info.put_utf_string("zn", "MySingingPenis") - auth_info.put_utf_string("un", "") - auth_info.put_utf_string("pw", "") - auth_info.put_sfs_object("p", SFSObject()) + auth_info = SFSObject() + auth_info.put_utf_string("zn", "MySingingPenis") + auth_info.put_utf_string("un", "") + auth_info.put_utf_string("pw", "") + auth_info.put_sfs_object("p", SFSObject()) - await conn.send(Message(ControllerID.SYSTEM, SysAction.LOGIN, auth_info)) + await conn.send(Message(ControllerID.SYSTEM, SysAction.LOGIN, auth_info)) - resp = await conn.recv() - assert resp.payload['ec'] == 1 \ No newline at end of file + resp = await conn.recv() + assert resp.payload['ec'] == 1 \ No newline at end of file