diff --git a/changelog.d/21.added b/changelog.d/21.added new file mode 100644 index 0000000..e69de29 diff --git a/src/cobbler_tftp/cli.py b/src/cobbler_tftp/cli.py index e54fb2e..b08dd76 100644 --- a/src/cobbler_tftp/cli.py +++ b/src/cobbler_tftp/cli.py @@ -2,17 +2,21 @@ Cobbler-tftp will be managable as a command-line service. """ +import os from pathlib import Path +from signal import SIGTERM from typing import List, Optional import click import yaml +from daemon import DaemonContext try: import importlib.metadata as importlib_metadata except ImportError: # use backport for Python versions older than 3.8 import importlib_metadata +from cobbler_tftp.server import run_server from cobbler_tftp.settings import SettingsFactory try: @@ -74,20 +78,29 @@ def start( """ click.echo(cli.__doc__) click.echo("Initializing Cobbler-tftp server...") - settings_factory: SettingsFactory = SettingsFactory() - # settings_file = SettingsFactory.load_config_file(settings_factory, config) - # environment_variables = SettingsFactory.load_env_variables(settings_factory) - # cli_arguments = SettingsFactory.load_cli_options( - # settings_factory, daemon, enable_automigration, settings - # ) if config is None: config_path = None else: config_path = Path(config) - application_settings = SettingsFactory.build_settings( - settings_factory, config_path, daemon, enable_automigration, settings + application_settings = SettingsFactory().build_settings( + config_path, daemon, enable_automigration, settings ) - print(application_settings) + if application_settings.is_daemon: + click.echo("Starting daemon...") + with DaemonContext(): + # All previously open file descriptors are invalid now. + # Files and connections needed for the daemon should be opened + # in run_server or listed in the files_preserve option + # of DaemonContext. + + application_settings.pid_file_path.write_text(str(os.getpid())) + try: + run_server(application_settings) + finally: + application_settings.pid_file_path.unlink() + else: + click.echo("Daemon mode disabled, running in foreground.") + run_server(application_settings) @cli.command() @@ -103,16 +116,37 @@ def print_default_config(): """ Print the default application parameters. """ - settings_factory: SettingsFactory = SettingsFactory() - click.echo(settings_factory.build_settings(None)) + click.echo(SettingsFactory().build_settings(None)) @cli.command() -def stop(): +@click.option( + "--config", "-c", type=click.Path(), help="Set location of configuration file." +) +@click.option("--pid-file", "-p", type=click.Path(), help="Set location of PID file.") +def stop(config: Optional[str], pid_file: Optional[str]): """ Stop the cobbler-tftp server daemon if it is running """ - pass + if pid_file is None: + if config is None: + config_path = None + else: + config_path = Path(config) + application_settings = SettingsFactory().build_settings(config_path) + pid_file_path = application_settings.pid_file_path + else: + pid_file_path = Path(pid_file) + try: + pid = int(pid_file_path.read_text()) + except OSError: + click.echo("Unable to read PID file. The daemon is probably not running.") + return + try: + os.kill(pid, SIGTERM) + except ProcessLookupError: + click.echo("Stale PID file. The daemon is no longer running.") + pid_file_path.unlink() cli.add_command(start) diff --git a/src/cobbler_tftp/server/__init__.py b/src/cobbler_tftp/server/__init__.py new file mode 100644 index 0000000..bea0efa --- /dev/null +++ b/src/cobbler_tftp/server/__init__.py @@ -0,0 +1,38 @@ +""" +This package contains the actual TFTP server. +""" + +import logging +import logging.config + +from cobbler_tftp.server.tftp import TFTPServer +from cobbler_tftp.settings import Settings + +try: + from importlib.resources import files +except ImportError: + from importlib_resources import files + + +def run_server(application_settings: Settings): + """Set up logging, initialize the server and run it.""" + + logging_conf = application_settings.logging_conf + if logging_conf is None or not logging_conf.exists(): + logging_conf = files("cobbler_tftp.settings.data").joinpath("logging.conf") + 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) + except: # pylint: disable=bare-except + logging.exception("Fatal exception while setting up server") + return + try: + server.run() + except: # pylint: disable=bare-except + logging.exception("Fatal exception in server") + server.close() diff --git a/src/cobbler_tftp/server/tftp.py b/src/cobbler_tftp/server/tftp.py new file mode 100644 index 0000000..8f8a126 --- /dev/null +++ b/src/cobbler_tftp/server/tftp.py @@ -0,0 +1,82 @@ +""" +This module contains the main TFTP server class. +""" + +import logging + +from fbtftp import BaseHandler, BaseServer, ResponseData + + +class CobblerResponseData(ResponseData): + """File-like object representing the response from the TFTP server.""" + + def __init__(self): + pass + + def read(self, n): + return b"" + + def size(self): + return 0 + + def close(self): + pass + + +def handler_stats_cb(stats): + duration = stats.duration() * 1000 + logging.info( + f"Spent {duration}ms processing request for {stats.file_path} from {stats.peer}" + ) + logging.info( + f"Error: {stats.error}, sent {stats.bytes_sent} bytes with {stats.retransmits} retransmits" + ) + + +def server_stats_cb(stats): + """ + Called by the fbtftp to log server stats. Currently unused. + """ + + +class CobblerRequestHandler(BaseHandler): + """ + Handles TFTP requests using the Cobbler API. + """ + + def __init__(self, server_addr, peer, path, options): + """ + Initialize a handler for a specific request. + + :param server_addr: Tuple containing the server address and port. + :param peer: Tuple containing the client address and port. + :param path: Request file path. + :param options: Options requested by the client. + """ + # Future arguments can be handled here + super().__init__(server_addr, peer, path, options, handler_stats_cb) + + def get_response_data(self): + return CobblerResponseData() + + +class TFTPServer(BaseServer): + """ + Implements a TFTP server for the Cobbler API using the CobblerRequestHandler. + """ + + def __init__(self, address, port, retries, timeout): + """ + 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. + """ + # Future arguments can be handled here + self.handler_stats_cb = handler_stats_cb + super().__init__(address, port, retries, timeout, server_stats_cb) + + def get_handler(self, server_addr, peer, path, options): + return CobblerRequestHandler(server_addr, peer, path, options) diff --git a/src/cobbler_tftp/settings/__init__.py b/src/cobbler_tftp/settings/__init__.py index c96cbbc..a730eb3 100644 --- a/src/cobbler_tftp/settings/__init__.py +++ b/src/cobbler_tftp/settings/__init__.py @@ -26,10 +26,16 @@ def __init__( self, auto_migrate_settings: bool, is_daemon: bool, + pid_file_path: Path, uri: str, username: str, - password: Union[str, None], - password_file: Union[Path, None], + password: Optional[str], + password_file: Optional[Path], + tftp_addr: str, + tftp_port: int, + tftp_retries: int, + tftp_timeout: int, + logging_conf: Optional[Path], ) -> None: """ Initialize a new instance of the Settings. @@ -45,10 +51,16 @@ def __init__( self.auto_migrate_settings: bool = auto_migrate_settings self.is_daemon: bool = is_daemon + self.pid_file_path: Path = pid_file_path self.uri: str = uri self.user: str = username - self.__password: Union[str, None] = password - self.__password_file: Union[Path, None] = password_file + 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.__password: Optional[str] = password + self.__password_file: Optional[Path] = password_file def __repr__(self): """ @@ -126,7 +138,8 @@ def build_settings( # Type ignores are necessary as at this point it is not known what value comes from that key. auto_migrate_settings: bool = self._settings_dict.get("auto_migrate_settings", False) # type: ignore is_daemon: bool = self._settings_dict.get("is_daemon", False) # type: ignore - cobbler_settings = self._settings_dict.get("cobbler", None) + pid_file_path: Path = Path(self._settings_dict.get("pid_file_path", "/run/cobbler-tftp.pid")) # type: ignore + cobbler_settings = self._settings_dict.get("cobbler", {}) uri: str = cobbler_settings.get("uri", "") # type: ignore username: str = cobbler_settings.get("username", "") # type: ignore password: str = cobbler_settings.get("password", "") # type: ignore @@ -134,15 +147,30 @@ 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) + 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 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: + logging_conf = None # Create and return a new Settings object settings = Settings( auto_migrate_settings, is_daemon, + pid_file_path, uri, username, password, password_file, + tftp_addr, + tftp_port, + tftp_retries, + tftp_timeout, + logging_conf, ) return settings diff --git a/src/cobbler_tftp/settings/data/logging.conf b/src/cobbler_tftp/settings/data/logging.conf new file mode 100644 index 0000000..73fa740 --- /dev/null +++ b/src/cobbler_tftp/settings/data/logging.conf @@ -0,0 +1,40 @@ +# based on https://github.com/cobbler/cobbler/blob/main/config/cobbler/logging_config.conf +[loggers] +keys=root + +[handlers] +keys=FileLogger,stdout + +[formatters] +keys=Logfile,stdout + +[logger_root] +level=DEBUG +handlers=FileLogger,stdout + +[logger_parser] +level=DEBUG +handlers=FileLogger +propagate=1 +qualname=compiler.parser + +[handler_stdout] +class=StreamHandler +level=INFO +formatter=stdout +args=(sys.stdout,) + +[handler_FileLogger] +class=FileHandler +level=INFO +formatter=Logfile +args=('/var/log/cobbler-tftp.log', 'a') + +[formatter_Logfile] +format=[%(threadName)s] %(asctime)s - %(levelname)s | %(message)s +datefmt=%Y-%m-%dT%H:%M:%S +class=logging.Formatter + +[formatter_stdout] +format=%(levelname)s | %(message)s +class=logging.Formatter diff --git a/src/cobbler_tftp/settings/data/settings.yml b/src/cobbler_tftp/settings/data/settings.yml index cbc7d7d..0339be9 100644 --- a/src/cobbler_tftp/settings/data/settings.yml +++ b/src/cobbler_tftp/settings/data/settings.yml @@ -3,9 +3,17 @@ schema: 1.0 auto_migrate_settings: false # Run cobbler-tftp as a daemon in the background is_daemon: true +pid_file_path: "/run/cobbler-tftp.pid" # Specifications of the cobbler-server cobbler: uri: "http://localhost/cobbler_api" username: "cobbler" password: "cobbler" - # password_file: "/etc/cobbler-tftp/cobbler_password" \ No newline at end of file + # password_file: "/etc/cobbler-tftp/cobbler_password" +# TFTP server configuration +tftp: + address: "127.0.0.1" + port: 69 + retries: 5 + timeout: 2 +logging_conf: "/etc/cobbler-tftp/logging.conf" diff --git a/src/cobbler_tftp/settings/migrations/__init__.py b/src/cobbler_tftp/settings/migrations/__init__.py index 89bb2a5..99923ae 100644 --- a/src/cobbler_tftp/settings/migrations/__init__.py +++ b/src/cobbler_tftp/settings/migrations/__init__.py @@ -131,9 +131,9 @@ def __validate_module(name: ModuleType) -> bool: """ # noqa for these lines because we can't use the custom types to check this. module_methods = { - "validate": "(settings_dict:Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[str,pathlib.Path]]]])->bool", # noqa - "normalize": "(settings_dict:Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[str,pathlib.Path]]]])->Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[str,pathlib.Path]]]]", # noqa - "migrate": "(settings_dict:Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[str,pathlib.Path]]]])->Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[str,pathlib.Path]]]]", # noqa + "validate": "(settings_dict:Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[int,str,pathlib.Path]]]])->bool", # noqa + "normalize": "(settings_dict:Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[int,str,pathlib.Path]]]])->Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[int,str,pathlib.Path]]]]", # noqa + "migrate": "(settings_dict:Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[int,str,pathlib.Path]]]])->Dict[str,Union[float,bool,str,pathlib.Path,Dict[str,Union[int,str,pathlib.Path]]]]", # noqa } for key, value in module_methods.items(): diff --git a/src/cobbler_tftp/settings/migrations/v1_0.py b/src/cobbler_tftp/settings/migrations/v1_0.py index 9961948..e449d08 100644 --- a/src/cobbler_tftp/settings/migrations/v1_0.py +++ b/src/cobbler_tftp/settings/migrations/v1_0.py @@ -11,11 +11,19 @@ Optional("schema"): float, Optional("auto_migrate_settings"): bool, Optional("is_daemon"): bool, + Optional("pid_file_path"): str, Optional("cobbler"): { Optional("uri"): str, Optional("username"): str, Optional(Or("password", "password_file", only_one=True)): Or(str, Path), }, + Optional("tftp"): { + Optional("address"): str, + Optional("port"): int, + Optional("retries"): int, + Optional("timeout"): int, + }, + Optional("logging_conf"): str, } ) diff --git a/src/cobbler_tftp/types/__init__.py b/src/cobbler_tftp/types/__init__.py index fe7f153..1d1d29b 100644 --- a/src/cobbler_tftp/types/__init__.py +++ b/src/cobbler_tftp/types/__init__.py @@ -5,4 +5,6 @@ # Dictionary type for configuration parameters # if this type changes: changes __valdiate_module function in migrations/__init__.py -SettingsDict = Dict[str, Union[float, bool, str, Path, Dict[str, Union[str, Path]]]] +SettingsDict = Dict[ + str, Union[float, bool, str, Path, Dict[str, Union[int, str, Path]]] +] diff --git a/tests/test_data/valid_config.yml b/tests/test_data/valid_config.yml index 75afccf..0947a94 100644 --- a/tests/test_data/valid_config.yml +++ b/tests/test_data/valid_config.yml @@ -4,4 +4,9 @@ is_daemon: false cobbler: uri: 'http://testmachine.testnetwork.com/api' username: 'cobbler' - password_file: 'tests/test_data/password_file' \ No newline at end of file + password_file: 'tests/test_data/password_file' +tftp: + address: '0.0.0.0' + port: 1969 + retries: 10 + timeout: 3 diff --git a/tests/unittests/application_settings/conftest.py b/tests/unittests/application_settings/conftest.py index e7617d1..9a30562 100644 --- a/tests/unittests/application_settings/conftest.py +++ b/tests/unittests/application_settings/conftest.py @@ -1,6 +1,8 @@ """ This module implements all necessary fixtures for running the unittests using pytests. They are automaticall discovered. """ +from pathlib import Path + import pytest from cobbler_tftp.types import SettingsDict @@ -18,11 +20,19 @@ def fake_settings_dict() -> SettingsDict: "schema": 1.0, "auto_migrate_settings": True, "is_daemon": True, + "pid_file_path": Path("/run/cobbler-tftp.pid"), "cobbler": { "uri": "http://localhost/cobbler_api", "username": "cobbler", "password": "cobbler", }, + "tftp": { + "addr": "127.0.0.1", + "port": 69, + "timeout": 2, + "retries": 5, + }, + "logging_conf": "/etc/cobbler-tftp/logging.conf", } return fake_settings_dict diff --git a/tests/unittests/application_settings/test_settings.py b/tests/unittests/application_settings/test_settings.py index fafb2a4..ea1b415 100644 --- a/tests/unittests/application_settings/test_settings.py +++ b/tests/unittests/application_settings/test_settings.py @@ -17,6 +17,20 @@ def settings_factory(): return SettingsFactory() +def assert_default_settings(settings): + assert settings.auto_migrate_settings is False + assert settings.is_daemon is True + assert str(settings.pid_file_path) == "/run/cobbler-tftp.pid" + assert settings.uri == "http://localhost/cobbler_api" + assert settings.user == "cobbler" + assert settings.password == "cobbler" + assert settings.tftp_addr == "127.0.0.1" + assert settings.tftp_port == 69 + assert settings.tftp_retries == 5 + assert settings.tftp_timeout == 2 + assert str(settings.logging_conf) == "/etc/cobbler-tftp/logging.conf" + + def test_build_settings_with_default_config_file( settings_factory: SettingsFactory, mocker ): @@ -31,11 +45,7 @@ def test_build_settings_with_default_config_file( # Assert that the expected values are set in the Settings object assert isinstance(settings, Settings) - assert settings.auto_migrate_settings is False - assert settings.is_daemon is True - assert settings.uri == "http://localhost/cobbler_api" - assert settings.user == "cobbler" - assert settings.password == "cobbler" + assert_default_settings(settings) def test_build_settings_with_valid_config_file( @@ -50,6 +60,10 @@ def test_build_settings_with_valid_config_file( assert settings.uri == "http://testmachine.testnetwork.com/api" assert settings.user == "cobbler" assert settings.password == "password" + assert settings.tftp_addr == "0.0.0.0" + assert settings.tftp_port == 1969 + assert settings.tftp_retries == 10 + assert settings.tftp_timeout == 3 def test_build_settings_with_invalid_config_file( @@ -89,8 +103,4 @@ def test_build_settings_with_missing_config_file( captured_message = capsys.readouterr() assert captured_message.out == expected_message assert isinstance(settings, Settings) - assert settings.auto_migrate_settings is False - assert settings.is_daemon is True - assert settings.uri == "http://localhost/cobbler_api" - assert settings.user == "cobbler" - assert settings.password == "cobbler" + assert_default_settings(settings)