diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a83a8cc --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug client", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/src/judas_client/__main__.py", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index a04f6a3..486fe01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,9 @@ description = "A client for judas, a remote PC fleet management system." readme = "README.md" authors = [] requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "judas-protocol", +] license = { text = "GPL-3.0+" } [dependency-groups] @@ -81,3 +83,6 @@ allowed_tags = [ minor_tags = ["feat"] patch_tags = ["fix", "perf"] default_bump_level = 0 + +[tool.uv.sources] +judas-protocol = { git = "https://gitea.pufereq.pl/judas/judas_protocol.git" } diff --git a/src/judas_client/__main__.py b/src/judas_client/__main__.py new file mode 100644 index 0000000..e67d856 --- /dev/null +++ b/src/judas_client/__main__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + + +import logging as lg + +from judas_client.connector import Connector + +if __name__ == "__main__": + lg.basicConfig( + level=lg.DEBUG, + format="%(asctime)s : [%(levelname)s] : %(threadName)s : %(name)s :: %(message)s", + ) + logger = lg.getLogger(__name__) + + connector = Connector("127.0.0.1", 3692) + connector.run() diff --git a/src/judas_client/connector.py b/src/judas_client/connector.py new file mode 100644 index 0000000..bfa71d0 --- /dev/null +++ b/src/judas_client/connector.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import logging as lg + +import socket +import uuid +import time + +from judas_protocol import Message + + +class Connector: + def __init__( + self, + host: str, + port: int, + connect_timeout: float = 5.0, + ack_timeout: float = None, + ) -> None: + self.logger: lg.Logger = lg.getLogger( + f"{__name__}.{self.__class__.__name__}" + ) + self.logger.debug("Initializing Connector...") + + self.host: str = host + self.port: int = port + self.socket_timeout: None = None + self.connect_timeout: float = connect_timeout + self.ack_timeout: float = ack_timeout + + self.socket: socket.socket = socket.socket( + socket.AF_INET, socket.SOCK_STREAM + ) + + self.mac_address: str = self._get_mac_address() + + def _get_mac_address(self) -> str: + mac_address: str = ":".join( + [ + "{:02x}".format((uuid.getnode() >> ele) & 0xFF) + for ele in range(0, 8 * 6, 8) + ][::-1] + ) + self.logger.debug(f"MAC address: {mac_address}") + return mac_address + + def _send_ack(self) -> None: + self.logger.debug("[>] Sending ACK...") + try: + self.socket.sendall(Message.ack().to_bytes()) + self.logger.debug("[<] ACK sent") + except socket.error as e: + self.logger.error(f"[!] Failed to send ACK: {e}") + + def _check_ack(self) -> bool: + self.logger.debug("[.] Waiting for ACK...") + try: + self.socket.settimeout(self.ack_timeout) + ack: bytes = self.socket.recv(1024) + self.socket.settimeout(self.socket_timeout) + + if ack == Message.ack().to_bytes(): + self.logger.debug("[<] ACK received") + return True + else: + self.logger.error(f"[!] Invalid ACK received: {ack}") + + except TimeoutError as e: + self.logger.error(f"[!] ACK timeout: {e}") + + except socket.error as e: + self.logger.error(f"[!] Failed to receive ACK: {e}") + + return False + + def connect(self, retry_interval: int = 1) -> None: + self.logger.debug( + f"Connecting to {self.host}:{self.port} with timeout {self.connect_timeout}s..." + ) + try: + self.socket.settimeout(self.connect_timeout) + self.socket.connect((self.host, self.port)) + self.socket.settimeout(self.socket_timeout) + self.logger.info(f"[+] Connected to {self.host}:{self.port}") + self.send_hello() + except ( + socket.timeout, + ConnectionRefusedError, + ConnectionAbortedError, + ) as e: + self.logger.error( + f"[!] Connection to {self.host}:{self.port} failed: {e}" + ) + self.logger.info( + f"[.] Retrying connection in {retry_interval} s..." + ) + time.sleep(retry_interval) + self.connect(retry_interval=min(30, retry_interval * 2)) + + def send(self, data: bytes) -> None: + self.logger.debug(f"[>] Sending data: {data}") + try: + self.socket.sendall(data) + self.logger.info("[>] Data sent") + self._send_ack() + except BrokenPipeError as e: + self.logger.error(f"[!] Broken pipe: {e}") + self.logger.info("[.] Reconnecting...") + self.connect() + self.send(data) + except (socket.error, ValueError) as e: + self.logger.error(f"[!] Failed to send data: {e}") + + def receive(self) -> bytes: + self.logger.debug("[.] Waiting to receive data...") + try: + data: bytes = self.socket.recv(4096) + if not data: + self.logger.warning("[!] Received empty message") + return b"" + self.logger.debug(f"[<] Received data: {data}") + return data + except socket.error as e: + self.logger.error(f"[!] Failed to receive data: {e}") + return b"" + + def close(self) -> None: + self.logger.debug("Closing connection...") + self.socket.close() + self.logger.info("Connection closed.") + + def reconnect(self) -> None: + self.logger.debug("Reconnecting...") + self.close() + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.connect() + + def send_hello(self) -> None: + self.logger.debug("[.] Sending hello message...") + hello_message: Message = Message.hello(self.mac_address) + acknowledged: bool = False + while not acknowledged: + self.send(hello_message.to_bytes()) + self.logger.debug("[.] Hello message sent, waiting for ACK...") + acknowledged = self._check_ack() + if not acknowledged: + self.logger.warning( + "[!] Hello message not acknowledged, retrying..." + ) + time.sleep(1) + + def _loop(self) -> None: + self.logger.debug("Starting main loop...") + while True: + time.sleep(1) + data: bytes = self.receive() + if not data: + self.reconnect() + continue + message = Message.from_bytes(data.strip()) + self.logger.info(f"[<] Message received: {message}") + # if self._check_ack(): + # self.logger.debug("[.] ACK verified") + # else: + # self.logger.error("[!] ACK verification failed") + + def run(self) -> None: + self.logger.debug("Running Connector...") + try: + self.connect() + self._loop() + except KeyboardInterrupt: + self.logger.info("Interrupted by user.") + finally: + self.close() diff --git a/uv.lock b/uv.lock index e23c460..74934f4 100644 --- a/uv.lock +++ b/uv.lock @@ -242,6 +242,9 @@ wheels = [ name = "judas-client" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "judas-protocol" }, +] [package.dev-dependencies] bump = [ @@ -256,6 +259,7 @@ test = [ ] [package.metadata] +requires-dist = [{ name = "judas-protocol", git = "https://gitea.pufereq.pl/judas/judas_protocol.git" }] [package.metadata.requires-dev] bump = [ @@ -269,6 +273,11 @@ test = [ { name = "pytest-mock", specifier = ">=3.14.1" }, ] +[[package]] +name = "judas-protocol" +version = "0.1.0" +source = { git = "https://gitea.pufereq.pl/judas/judas_protocol.git#fd070b176347a0f7b81f937b189d8f50736f3514" } + [[package]] name = "markdown-it-py" version = "4.0.0"