diff --git a/setup.py b/setup.py index b342b01..926d396 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup +from setuptools import setup, find_packages setup( @@ -13,10 +13,10 @@ setup( author_email='unit@paranoici.org', package_dir={'': 'src'}, - packages=['phi', 'phi.api', 'phi.ldap'], - scripts=['src/phid'], + packages=find_packages(), + entry_points={"console_scripts": ["phid=phi.app:cli"]}, setup_requires=['pytest-runner'], - install_requires=['aiohttp==2.3.8', 'pyYAML', 'ldap3'], + install_requires=['aiohttp==2.3.8', 'click==7.0', 'pyYAML', 'ldap3'], tests_require=['pytest', 'pytest-aiohttp'] ) diff --git a/src/phi/app.py b/src/phi/app.py index 60fcd87..6ca3315 100644 --- a/src/phi/app.py +++ b/src/phi/app.py @@ -1,22 +1,214 @@ +# -*- encoding: utf-8 -*- +import pkg_resources + from asyncio import get_event_loop from aiohttp import web +import click +from pprint import pformat as pp +import yaml +from phi.config import get_config, merge_config +from phi.logging import setup_logging, get_logger from phi.api.app import api_app +log = get_logger(__name__) + def setup_app(config): loop = get_event_loop() app = web.Application(loop=loop) - app['config'] = config + app["config"] = config api = api_app(config) - app.add_subapp('/api', api) + app.add_subapp("/api", api) return app def run_app(app): - web.run_app(app, - host=app['config']['core']['listen'].get('host', '127.0.0.1'), - port=app['config']['core']['listen'].get('port', '8080')) + web.run_app( + app, + host=app["config"]["core"]["listen"].get("host", "127.0.0.1"), + port=app["config"]["core"]["listen"].get("port", "8080"), + ) + + +def _validate_port(ctx, param, value): + """ + Callback to validate provided port. + """ + if 0 < value or value > 65535: + raise click.BadParameter( + "Provided value is not in the range 0-65535: {} [type: {}]".format( + value, type(value) + ) + ) + return value + + +@click.command(help="phid is the main application daemon.") +@click.option( + "--config", + "config_path", + type=click.Path(exists=True), + help="Path to a valid config file.", +) +@click.option( + "-H", + "--host", + type=click.STRING, + default="localhost", + help='Address to which the application bounds. Defaults to "localhost".', +) +@click.option( + "-p", + "--port", + type=click.INT, + default=8080, + help="Port to which the application bounds. Defaults to 8080.", +) +@click.option( + "--ldap-host", + "ldap_host", + type=click.STRING, + default="localhost", + help='Address of the LDAP server to connect to. Defaults to "localhost".', +) +@click.option( + "--ldap-port", + "ldap_port", + type=click.INT, + default=389, + help="Port where is exposed the LDAP server to connect to. Defaults to 389.", +) +@click.option( + "--ldap-crypt", + "ldap_crypt", + is_flag=True, + default=True, + help="Connect to the LDAP server using TLSv1.2. Defaults to True.", +) +@click.option( + "--ldap-tls-validate", + "ldap_tls_validate", + is_flag=True, + default=True, + help="Toggle checking of TLS cert against the provided name. Defaults to True.", +) +@click.option( + "--ldap-tls-ca", + "ldap_tls_ca", + type=click.Path(exists=True), + help="Toggle checking of TLS cert against the provided name. Defaults to True.", +) +@click.option( + "--ldap-base-dn", "ldap_base_dn", type=click.STRING, help="The LDAP base_dn to use." +) +@click.option( + "--ldap-username", + "ldap_username", + type=click.STRING, + help="The username to use to connect to the LDAP server.", +) +@click.option( + "--ldap-password", + "ldap_password", + type=click.STRING, + help="The password to use to connect to the LDAP server. " + "THIS CAN BE READ BY OTHER PROCESSES. NEVER USE IN PRODUCTION!", +) +@click.option( + "--log-conf", + "log_conf", + type=click.Path(exists=True), + help="Path to a yaml configuration for the logger.", +) +@click.option( + "--debug", + "debug", + is_flag=True, + default=False, + help="Set the log level to debug.", +) +def cli( + host, + port, + config_path=None, + ldap_host=None, + ldap_port=None, + ldap_crypt=True, + ldap_tls_validate=True, + ldap_tls_ca=None, + ldap_base_dn=None, + ldap_username=None, + ldap_password=None, + log_conf=None, + debug=False, +): + cli_config = prepare_config_from_cli( + host, + port, + ldap_host, + ldap_port, + ldap_crypt, + ldap_tls_validate, + ldap_tls_ca, + ldap_base_dn, + ldap_username, + ldap_password, + log_conf, + debug, + ) + config_file, file_config = get_config(config_path) + config = merge_config(cli_config, file_config) + if debug: + set_to_debug(config) + # Beware that everything happened until now + # could not possibly get logged. + setup_logging(config.get("logging", {})) + if config_file: + log.debug("Config file found at: %s", config_file) + log.debug("{}".format(pp(file_config))) + log.debug("CLI config:\n{}".format(pp(cli_config))) + log.info("Starting app with config:\n{}".format(pp(config))) + + app = setup_app(config) + run_app(app) + + +def prepare_config_from_cli( + host, + port, + ldap_host=None, + ldap_port=None, + ldap_crypt=True, + ldap_tls_validate=True, + ldap_tls_ca=None, + ldap_base_dn=None, + ldap_username=None, + ldap_password=None, + log_conf=None, + debug=False, +): + _core = {"listen": {"host": host, "port": port}} + _ldap = { + "encryption": "TLSv1.2" if ldap_crypt else None, + "validate": ldap_tls_validate, + "ca_certs": ldap_tls_ca, + "username": ldap_username, + "password": ldap_password, + "base_dn": ldap_base_dn, + } + _logging = {} + if log_conf: + with open(log_conf) as l: + _logging = yaml.safe_load(l) + + return {"core": _core, "ldap": _ldap, "logging": _logging} + + +def set_to_debug(conf): + for logger, log_conf in conf["logging"]["loggers"].items(): + log_conf["level"] = "DEBUG" + conf["logging"]["loggers"][logger] = log_conf diff --git a/src/phi/config.py b/src/phi/config.py index 259863d..f967e51 100644 --- a/src/phi/config.py +++ b/src/phi/config.py @@ -1,9 +1,66 @@ import os.path +import pkg_resources import yaml NAME = 'phi' +DEFAULT_CONFIG = { + "core": { + "listen": { + "host": "localhost", + "port": 8080, + } + }, + "ldap": { + "host": "localhost", + "port": 389, + "encryption": "TLSv1.2", + "ciphers": "HIGH", + "validate": True, + "ca_certs": pkg_resources.resource_filename(NAME, "openldap/cert.pem"), + "username": None, + "password": None, + "base_dn": None, + "attribute_id": "uid", + "attribute_mail": "mail", + }, + "logging": { + "version": 1, + "formatters": { + "default": {"format": "[%(name)s %(levelname)s] %(message)s"} + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + "stream": "ext://sys.stdout", + }, + "file": { + "class": "logging.FileHandler", + "formatter": "default", + "filename": "phi.log", + } + }, + "loggers": { + "phi": { + "level": "INFO", + "handlers": ["console", "file"], + }, + "aiohttp": { + "level": "INFO", + "handlers": ["console", "file"], + }, + "ldap3": { + "level": "WARNING", + "handlers": ["console", "file"], + } + } + } +} + +DUMMY_CONFIG = {"core": {}, "ldap": {}, "logging": {}} + CONFIG_FILE = 'config.yml' CONFIG_PATHS = ['./', '~/.config/' + NAME + '/', @@ -13,12 +70,18 @@ CONFIG_FILES = [os.path.join(p, CONFIG_FILE) for p in CONFIG_PATHS] -def get_config(): +def get_config(config_path=None): """Return the path of the found configuration file and its content + :param config_path: optional path to a config file. + :returns: (path, config) :rtype: (str, dict) """ + if config_path: + with open(config_path) as c: + config = yaml.safe_load(c) + return config_path, config for f in CONFIG_FILES: try: with open(f, 'r') as c: @@ -30,6 +93,47 @@ def get_config(): # accessible or if the file is not present at all # in any of CONFIG_PATHS. pass - else: - raise FileNotFoundError("Could not find {} in any of {}." - .format(CONFIG_FILE, ', '.join(CONFIG_PATHS))) + return None, DUMMY_CONFIG + + +def merge_config(cli_config, file_config): + """ + Merge the cli-provided and file-provided config. + """ + return recursive_merge(cli_config, file_config) + + +def _init_with_shape_of(element): + if isinstance(element, dict): + return {} + elif isinstance(element, list): + return [] + return None + + +def recursive_merge(main_config, aux_config): + def _recursive_merge(main, aux, default, key, _ret_config): + if isinstance(default, dict): + _sub_conf = {} + for k, v in default.items(): + _main = main[k] if k in main else _init_with_shape_of(v) + _aux = aux[k] if k in aux else _init_with_shape_of(v) + _recursive_merge(_main, _aux, v, k, _sub_conf) + _ret_config[key] = _sub_conf + elif isinstance(default, list): + _main = main.copy() + if aux is not None: + _main.extend(aux) + _ret_config[key] = list(set(_main)) + else: + if main is not None: + _ret_config[key] = main + elif aux is not None: + _ret_config[key] = aux + else: + _ret_config[key] = default + + _config = {} + _recursive_merge(main_config, aux_config, DEFAULT_CONFIG, "ROOT", _config) + + return _config["ROOT"] diff --git a/src/phid b/src/phid deleted file mode 100755 index 557979a..0000000 --- a/src/phid +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -from pprint import pformat as pp - -from phi.config import get_config -from phi.logging import setup_logging, get_logger -from phi.app import setup_app, run_app - -log = get_logger(__name__) - - -if __name__ == '__main__': - config_file, config = get_config() - - # Beware that everything happened until now - # could not possibly get logged. - setup_logging(config.get('logging', {})) - - log.info("Found configuration at '{}':\n{}" - .format(config_file, pp(config))) - - app = setup_app(config) - run_app(app)