From f8ffd18d15becf02b40b9a446944c6aa5afa744f Mon Sep 17 00:00:00 2001 From: Affe Null Date: Thu, 11 Jan 2024 13:42:16 +0100 Subject: [PATCH] Implement file access via Cobbler XMLRPC API with static fallback --- src/cobbler_tftp/server/__init__.py | 9 +- src/cobbler_tftp/server/tftp.py | 118 ++++++++++++++++--- src/cobbler_tftp/settings/__init__.py | 15 ++- src/cobbler_tftp/settings/data/settings.yml | 5 + src/cobbler_tftp/settings/migrations/v1_0.py | 2 + 5 files changed, 126 insertions(+), 23 deletions(-) diff --git a/src/cobbler_tftp/server/__init__.py b/src/cobbler_tftp/server/__init__.py index 2f81ed9..92c3968 100644 --- a/src/cobbler_tftp/server/__init__.py +++ b/src/cobbler_tftp/server/__init__.py @@ -4,6 +4,7 @@ import logging import logging.config +import xmlrpc.client from cobbler_tftp.server.tftp import TFTPServer from cobbler_tftp.settings import Settings @@ -23,11 +24,9 @@ def run_server(application_settings: Settings): logging.config.fileConfig(str(logging_conf)) logging.debug("Server starting...") try: - address = application_settings.tftp_addr - port = application_settings.tftp_port - retries = application_settings.tftp_retries - timeout = application_settings.tftp_timeout - server = TFTPServer(address, port, retries, timeout) + # TODO: use username and password + api = xmlrpc.client.Server(application_settings.uri) + server = TFTPServer(api, application_settings) except: # pylint: disable=bare-except logging.exception("Fatal exception while setting up server") return diff --git a/src/cobbler_tftp/server/tftp.py b/src/cobbler_tftp/server/tftp.py index c4d9956..6b0d67b 100644 --- a/src/cobbler_tftp/server/tftp.py +++ b/src/cobbler_tftp/server/tftp.py @@ -3,21 +3,91 @@ """ import logging +import os +import xmlrpc.client from fbtftp import BaseHandler, BaseServer, ResponseData class CobblerResponseData(ResponseData): - """File-like object representing the response from the TFTP server.""" + """ + File-like object representing the response from the TFTP server. + Data is fetched from the API in chunks. These chunks may be larger + than the TFTP request chunks, so the returned chunks are cached. + """ - def __init__(self): - pass + def __init__(self, api, path, prefetch_size, static_dir): + self._api = api + self._path = path + self._size = None + self._chunk = None + self._chunk_offset = 0 + self._file_offset = 0 + self._prefetch_size = prefetch_size + self._static_file = None + self._file_exists = False + self._static_dir = static_dir + self._file_error = None + + def _file_fallback(self, err): + logging.warning("Could not fetch %s from server: %r", self._path, err) + if self._static_dir is None: + raise err + path = os.path.normpath(os.path.join("/", self._path)).strip("/") + self._static_file = open(self._static_dir / path, "rb") + self._size = self._static_file.seek(0, os.SEEK_END) + self._static_file.seek(0) def read(self, n): - return b"" + if self._file_error is not None: + raise self._file_error + if self._static_file is not None: + return self._static_file.read(n) + + if n > self._prefetch_size: + raise ValueError("Chunk too large") + if self._chunk is not None: + end = self._chunk_offset + n + if end <= self._prefetch_size: + data = self._chunk[self._chunk_offset : end] + self._chunk_offset = end + return data + self._file_offset += self._chunk_offset + try: + binary, self._size = self._api.get_tftp_file( + self._path, self._file_offset, self._prefetch_size + ) + except xmlrpc.client.Error as err: + if self._file_exists: + raise err + self._file_fallback(err) + return self.read(n) + self._file_exists = True + self._chunk = binary.data + data = self._chunk[:n] + self._chunk_offset = len(data) + return data def size(self): - return 0 + if self._chunk is None: + try: + binary, self._size = self._api.get_tftp_file( + self._path, 0, self._prefetch_size + ) + except xmlrpc.client.Error as err: + # fbtftp does not handle errors in size(), so the exception + # needs to be cached and raised in read(). + if self._file_exists: + self._file_error = err + else: + try: + self._file_fallback(err) + except Exception as err: + self._file_error = err + else: + self._chunk = binary.data + self._file_exists = True + return self._size def close(self): pass @@ -50,7 +120,7 @@ class CobblerRequestHandler(BaseHandler): Handles TFTP requests using the Cobbler API. """ - def __init__(self, server_addr, peer, path, options): + def __init__(self, server_addr, peer, path, options, api, settings): """ Initialize a handler for a specific request. @@ -58,12 +128,20 @@ def __init__(self, server_addr, peer, path, options): :param peer: Tuple containing the client address and port. :param path: Request file path. :param options: Options requested by the client. + :param api: The Cobbler API object. + :param static_fallback_dir: Path to directory with static TFTP files. """ - # Future arguments can be handled here + self._api = api + self._settings = settings super().__init__(server_addr, peer, path, options, handler_stats_cb) def get_response_data(self): - return CobblerResponseData() + return CobblerResponseData( + self._api, + self._path, + self._settings.prefetch_size, + self._settings.static_fallback_dir, + ) class TFTPServer(BaseServer): @@ -71,18 +149,24 @@ class TFTPServer(BaseServer): Implements a TFTP server for the Cobbler API using the CobblerRequestHandler. """ - def __init__(self, address, port, retries, timeout): + def __init__(self, api, settings): """ Initialize the TFTP server. - :param address: IP address to listen on. - :param port: UDP Port to listen on. - :param retries: Maximum number of retries when sending a packet fails. - :param timeout: Timeout for sending packets. + :param api: The Cobbler API object. + :param settings: The cobbler-tftp settings. """ - # Future arguments can be handled here - self.handler_stats_cb = handler_stats_cb - super().__init__(address, port, retries, timeout, server_stats_cb) + self._api = api + self._settings = settings + super().__init__( + settings.tftp_addr, + settings.tftp_port, + settings.tftp_retries, + settings.tftp_timeout, + server_stats_cb, + ) def get_handler(self, server_addr, peer, path, options): - return CobblerRequestHandler(server_addr, peer, path, options) + return CobblerRequestHandler( + server_addr, peer, path, options, self._api, self._settings + ) diff --git a/src/cobbler_tftp/settings/__init__.py b/src/cobbler_tftp/settings/__init__.py index cb205fe..f9b5540 100644 --- a/src/cobbler_tftp/settings/__init__.py +++ b/src/cobbler_tftp/settings/__init__.py @@ -31,11 +31,13 @@ def __init__( username: str, password: Optional[str], password_file: Optional[Path], + prefetch_size: int, tftp_addr: str, tftp_port: int, tftp_retries: int, tftp_timeout: int, logging_conf: Optional[Path], + static_fallback_dir: Optional[Path], ) -> None: """ Initialize a new instance of the Settings. @@ -46,6 +48,8 @@ def __init__( :param username: Username to authenticate at Cobbler's API. :param password: Password for authentication with Cobbler. :param password_file: Path to the file containing the password. + :param prefetch_size: Chunk size when fetching files from Cobbler. + :param static_fallback_dir: Path to the directory with static TFTP files. """ # pylint: disable=R0913 @@ -54,11 +58,13 @@ def __init__( self.pid_file_path: Path = pid_file_path self.uri: str = uri self.user: str = username + self.prefetch_size: int = prefetch_size self.tftp_addr: str = tftp_addr self.tftp_port: int = tftp_port self.tftp_retries: int = tftp_retries self.tftp_timeout: int = tftp_timeout self.logging_conf: Optional[Path] = logging_conf + self.static_fallback_dir: Optional[Path] = static_fallback_dir self.__password: Optional[str] = password self.__password_file: Optional[Path] = password_file @@ -147,11 +153,16 @@ def build_settings( password_file: Optional[Path] = Path(cobbler_settings.get("password_file", None)) # type: ignore else: password_file = None - tftp_settings = self._settings_dict.get("tftp", None) + prefetch_size: int = self._settings_dict.get("prefetch_size", 4096) # type: ignore + tftp_settings = self._settings_dict.get("tftp", {}) tftp_addr: str = tftp_settings.get("address", "127.0.0.1") # type: ignore tftp_port: int = tftp_settings.get("port", 69) # type: ignore tftp_retries: int = tftp_settings.get("retries", 5) # type: ignore tftp_timeout: int = tftp_settings.get("timeout", 2) # type: ignore + if tftp_settings.get("static_fallback_dir", None) is not None: # type: ignore + static_fallback_dir: Optional[Path] = Path(tftp_settings.get("static_fallback_dir", None)) # type: ignore + else: + static_fallback_dir = None if self._settings_dict.get("logging_conf", None) is not None: # type: ignore logging_conf: Optional[Path] = Path(self._settings_dict.get("logging_conf", None)) # type: ignore else: @@ -166,11 +177,13 @@ def build_settings( username, password, password_file, + prefetch_size, tftp_addr, tftp_port, tftp_retries, tftp_timeout, logging_conf, + static_fallback_dir, ) return settings diff --git a/src/cobbler_tftp/settings/data/settings.yml b/src/cobbler_tftp/settings/data/settings.yml index 0339be9..d90b8df 100644 --- a/src/cobbler_tftp/settings/data/settings.yml +++ b/src/cobbler_tftp/settings/data/settings.yml @@ -10,10 +10,15 @@ cobbler: username: "cobbler" password: "cobbler" # password_file: "/etc/cobbler-tftp/cobbler_password" +# Chunk size used for fetching files from Cobbler. +# Lower values result in slower transfers, higher values increase memory +# consumption. Extremely large values may cause TFTP timeouts. +prefetch_size: 4096 # TFTP server configuration tftp: address: "127.0.0.1" port: 69 retries: 5 timeout: 2 + static_fallback_dir: "/srv/tftpboot" logging_conf: "/etc/cobbler-tftp/logging.conf" diff --git a/src/cobbler_tftp/settings/migrations/v1_0.py b/src/cobbler_tftp/settings/migrations/v1_0.py index e449d08..ed54394 100644 --- a/src/cobbler_tftp/settings/migrations/v1_0.py +++ b/src/cobbler_tftp/settings/migrations/v1_0.py @@ -17,11 +17,13 @@ Optional("username"): str, Optional(Or("password", "password_file", only_one=True)): Or(str, Path), }, + Optional("prefetch_size"): int, Optional("tftp"): { Optional("address"): str, Optional("port"): int, Optional("retries"): int, Optional("timeout"): int, + Optional("static_fallback_dir"): str, }, Optional("logging_conf"): str, }