# -*- coding: utf-8 -*- """ ceph dashboard mgr plugin (based on CherryPy) """ from __future__ import absolute_import import collections from distutils.version import StrictVersion import errno import os import socket import ssl import sys import tempfile import threading import time from uuid import uuid4 from OpenSSL import crypto import _strptime # pylint: disable=unused-import from mgr_module import MgrModule, MgrStandbyModule, Option, CLIWriteCommand from mgr_util import get_default_addr, ServerConfigException, verify_tls_files try: import cherrypy from cherrypy._cptools import HandlerWrapperTool except ImportError: # To be picked up and reported by .can_run() cherrypy = None from .services.sso import load_sso_db if cherrypy is not None: from .cherrypy_backports import patch_cherrypy patch_cherrypy(cherrypy.__version__) if 'COVERAGE_ENABLED' in os.environ: import coverage __cov = coverage.Coverage(config_file="{}/.coveragerc".format(os.path.dirname(__file__)), data_suffix=True) cherrypy.engine.subscribe('start', __cov.start) cherrypy.engine.subscribe('after_request', __cov.save) cherrypy.engine.subscribe('stop', __cov.stop) # pylint: disable=wrong-import-position from . import logger, mgr from .controllers import generate_routes, json_error_page from .grafana import push_local_dashboards from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \ prepare_url_prefix, str_to_bool from .services.auth import AuthManager, AuthManagerTool, JwtManager from .services.sso import SSO_COMMANDS, \ handle_sso_command from .services.exception import dashboard_exception_handler from .settings import options_command_list, options_schema_list, \ handle_option_command from .plugins import PLUGIN_MANAGER from .plugins import feature_toggles, debug # noqa # pylint: disable=unused-import PLUGIN_MANAGER.hook.init() # cherrypy likes to sys.exit on error. don't let it take us down too! # pylint: disable=W0613 def os_exit_noop(*args): pass # pylint: disable=W0212 os._exit = os_exit_noop class CherryPyConfig(object): """ Class for common server configuration done by both active and standby module, especially setting up SSL. """ def __init__(self): self._stopping = threading.Event() self._url_prefix = "" self.cert_tmp = None self.pkey_tmp = None def shutdown(self): self._stopping.set() @property def url_prefix(self): return self._url_prefix @staticmethod def update_cherrypy_config(config): PLUGIN_MANAGER.hook.configure_cherrypy(config=config) cherrypy.config.update(config) # pylint: disable=too-many-branches def _configure(self): """ Configure CherryPy and initialize self.url_prefix :returns our URI """ server_addr = self.get_localized_module_option( 'server_addr', get_default_addr()) use_ssl = self.get_localized_module_option('ssl', True) if not use_ssl: server_port = self.get_localized_module_option('server_port', 8080) else: server_port = self.get_localized_module_option('ssl_server_port', 8443) if server_addr is None: raise ServerConfigException( 'no server_addr configured; ' 'try "ceph config set mgr mgr/{}/{}/server_addr "' .format(self.module_name, self.get_mgr_id())) self.log.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl else 'no', server_addr, server_port) # Initialize custom handlers. cherrypy.tools.authenticate = AuthManagerTool() cherrypy.tools.plugin_hooks_filter_request = cherrypy.Tool( 'before_handler', lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request), priority=1) cherrypy.tools.request_logging = RequestLoggingTool() cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler, priority=31) # Apply the 'global' CherryPy configuration. config = { 'engine.autoreload.on': False, 'server.socket_host': server_addr, 'server.socket_port': int(server_port), 'error_page.default': json_error_page, 'tools.request_logging.on': True, 'tools.gzip.on': True, 'tools.gzip.mime_types': [ # text/html and text/plain are the default types to compress 'text/html', 'text/plain', # We also want JSON and JavaScript to be compressed 'application/json', 'application/javascript', ], 'tools.json_in.on': True, 'tools.json_in.force': True, 'tools.plugin_hooks_filter_request.on': True, } if use_ssl: # SSL initialization cert = self.get_store("crt") if cert is not None: self.cert_tmp = tempfile.NamedTemporaryFile() self.cert_tmp.write(cert.encode('utf-8')) self.cert_tmp.flush() # cert_tmp must not be gc'ed cert_fname = self.cert_tmp.name else: cert_fname = self.get_localized_module_option('crt_file') pkey = self.get_store("key") if pkey is not None: self.pkey_tmp = tempfile.NamedTemporaryFile() self.pkey_tmp.write(pkey.encode('utf-8')) self.pkey_tmp.flush() # pkey_tmp must not be gc'ed pkey_fname = self.pkey_tmp.name else: pkey_fname = self.get_localized_module_option('key_file') verify_tls_files(cert_fname, pkey_fname) # Create custom SSL context to disable TLS 1.0 and 1.1. context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) context.load_cert_chain(cert_fname, pkey_fname) if sys.version_info >= (3, 7): context.minimum_version = ssl.TLSVersion.TLSv1_2 else: # Based on tests limiting TLS to specific versions # starts working with CherryPy >= 8.9.1 (Ubuntu 18.04 # or SUSE Leap 15.1). if StrictVersion(cherrypy.__version__) >= StrictVersion('8.9.1'): context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 config['server.ssl_module'] = 'builtin' config['server.ssl_certificate'] = cert_fname config['server.ssl_private_key'] = pkey_fname config['server.ssl_context'] = context self.update_cherrypy_config(config) self._url_prefix = prepare_url_prefix(self.get_module_option('url_prefix', default='')) uri = "{0}://{1}:{2}{3}/".format( 'https' if use_ssl else 'http', socket.getfqdn(server_addr if server_addr != '::' else ''), server_port, self.url_prefix ) return uri def await_configuration(self): """ Block until configuration is ready (i.e. all needed keys are set) or self._stopping is set. :returns URI of configured webserver """ while not self._stopping.is_set(): try: uri = self._configure() except ServerConfigException as e: self.log.info("Config not ready to serve, waiting: {0}".format( e )) # Poll until a non-errored config is present self._stopping.wait(5) else: self.log.info("Configured CherryPy, starting engine...") return uri class Module(MgrModule, CherryPyConfig): """ dashboard module entrypoint """ COMMANDS = [ { 'cmd': 'dashboard set-jwt-token-ttl ' 'name=seconds,type=CephInt', 'desc': 'Set the JWT token TTL in seconds', 'perm': 'w' }, { 'cmd': 'dashboard get-jwt-token-ttl', 'desc': 'Get the JWT token TTL in seconds', 'perm': 'r' }, { "cmd": "dashboard create-self-signed-cert", "desc": "Create self signed certificate", "perm": "w" }, { "cmd": "dashboard grafana dashboards update", "desc": "Push dashboards to Grafana", "perm": "w", }, ] COMMANDS.extend(options_command_list()) COMMANDS.extend(SSO_COMMANDS) PLUGIN_MANAGER.hook.register_commands() MODULE_OPTIONS = [ Option(name='server_addr', type='str', default=get_default_addr()), Option(name='server_port', type='int', default=8080), Option(name='ssl_server_port', type='int', default=8443), Option(name='jwt_token_ttl', type='int', default=28800), Option(name='password', type='str', default=''), Option(name='url_prefix', type='str', default=''), Option(name='username', type='str', default=''), Option(name='key_file', type='str', default=''), Option(name='crt_file', type='str', default=''), Option(name='ssl', type='bool', default=True), Option(name='standby_behaviour', type='str', default='redirect', enum_allowed=['redirect', 'error']), Option(name='standby_error_status_code', type='int', default=500, min=400, max=599) ] MODULE_OPTIONS.extend(options_schema_list()) for options in PLUGIN_MANAGER.hook.get_options() or []: MODULE_OPTIONS.extend(options) __pool_stats = collections.defaultdict(lambda: collections.defaultdict( lambda: collections.deque(maxlen=10))) def __init__(self, *args, **kwargs): super(Module, self).__init__(*args, **kwargs) CherryPyConfig.__init__(self) mgr.init(self) self._stopping = threading.Event() self.shutdown_event = threading.Event() self.ACCESS_CTRL_DB = None self.SSO_DB = None self.health_checks = {} @classmethod def can_run(cls): if cherrypy is None: return False, "Missing dependency: cherrypy" if not os.path.exists(cls.get_frontend_path()): return False, "Frontend assets not found: incomplete build?" return True, "" @classmethod def get_frontend_path(cls): current_dir = os.path.dirname(os.path.abspath(__file__)) return os.path.join(current_dir, 'frontend/dist') def serve(self): AuthManager.initialize() load_sso_db() uri = self.await_configuration() if uri is None: # We were shut down while waiting return # Publish the URI that others may use to access the service we're # about to start serving self.set_uri(uri) mapper, parent_urls = generate_routes(self.url_prefix) config = {} for purl in parent_urls: config[purl] = { 'request.dispatch': mapper } cherrypy.tree.mount(None, config=config) PLUGIN_MANAGER.hook.setup() cherrypy.engine.start() NotificationQueue.start_queue() TaskManager.init() logger.info('Engine started.') update_dashboards = str_to_bool( self.get_module_option('GRAFANA_UPDATE_DASHBOARDS', 'False')) if update_dashboards: logger.info('Starting Grafana dashboard task') TaskManager.run( 'grafana/dashboards/update', {}, push_local_dashboards, kwargs=dict(tries=10, sleep=60), ) # wait for the shutdown event self.shutdown_event.wait() self.shutdown_event.clear() NotificationQueue.stop() cherrypy.engine.stop() logger.info('Engine stopped') def shutdown(self): super(Module, self).shutdown() CherryPyConfig.shutdown(self) logger.info('Stopping engine...') self.shutdown_event.set() @CLIWriteCommand("dashboard set-ssl-certificate", "name=mgr_id,type=CephString,req=false") def set_ssl_certificate(self, mgr_id=None, inbuf=None): if inbuf is None: return -errno.EINVAL, '',\ 'Please specify the certificate file with "-i" option' if mgr_id is not None: self.set_store('{}/crt'.format(mgr_id), inbuf) else: self.set_store('crt', inbuf) return 0, 'SSL certificate updated', '' @CLIWriteCommand("dashboard set-ssl-certificate-key", "name=mgr_id,type=CephString,req=false") def set_ssl_certificate_key(self, mgr_id=None, inbuf=None): if inbuf is None: return -errno.EINVAL, '',\ 'Please specify the certificate key file with "-i" option' if mgr_id is not None: self.set_store('{}/key'.format(mgr_id), inbuf) else: self.set_store('key', inbuf) return 0, 'SSL certificate key updated', '' def handle_command(self, inbuf, cmd): # pylint: disable=too-many-return-statements res = handle_option_command(cmd, inbuf) if res[0] != -errno.ENOSYS: return res res = handle_sso_command(cmd) if res[0] != -errno.ENOSYS: return res if cmd['prefix'] == 'dashboard set-jwt-token-ttl': self.set_module_option('jwt_token_ttl', str(cmd['seconds'])) return 0, 'JWT token TTL updated', '' if cmd['prefix'] == 'dashboard get-jwt-token-ttl': ttl = self.get_module_option('jwt_token_ttl', JwtManager.JWT_TOKEN_TTL) return 0, str(ttl), '' if cmd['prefix'] == 'dashboard create-self-signed-cert': self.create_self_signed_cert() return 0, 'Self-signed certificate created', '' if cmd['prefix'] == 'dashboard grafana dashboards update': push_local_dashboards() return 0, 'Grafana dashboards updated', '' return (-errno.EINVAL, '', 'Command not found \'{0}\'' .format(cmd['prefix'])) def create_self_signed_cert(self): # create a key pair pkey = crypto.PKey() pkey.generate_key(crypto.TYPE_RSA, 2048) # create a self-signed cert cert = crypto.X509() cert.get_subject().O = "IT" cert.get_subject().CN = "ceph-dashboard" cert.set_serial_number(int(uuid4())) cert.gmtime_adj_notBefore(0) cert.gmtime_adj_notAfter(10*365*24*60*60) cert.set_issuer(cert.get_subject()) cert.set_pubkey(pkey) cert.sign(pkey, 'sha512') cert = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) self.set_store('crt', cert.decode('utf-8')) pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) self.set_store('key', pkey.decode('utf-8')) def notify(self, notify_type, notify_id): NotificationQueue.new_notification(notify_type, notify_id) def get_updated_pool_stats(self): df = self.get('df') pool_stats = {p['id']: p['stats'] for p in df['pools']} now = time.time() for pool_id, stats in pool_stats.items(): for stat_name, stat_val in stats.items(): self.__pool_stats[pool_id][stat_name].append((now, stat_val)) return self.__pool_stats def config_notify(self): """ This method is called whenever one of our config options is changed. """ PLUGIN_MANAGER.hook.config_notify() def refresh_health_checks(self): self.set_health_checks(self.health_checks) class StandbyModule(MgrStandbyModule, CherryPyConfig): def __init__(self, *args, **kwargs): super(StandbyModule, self).__init__(*args, **kwargs) CherryPyConfig.__init__(self) self.shutdown_event = threading.Event() # We can set the global mgr instance to ourselves even though # we're just a standby, because it's enough for logging. mgr.init(self) def serve(self): uri = self.await_configuration() if uri is None: # We were shut down while waiting return module = self class Root(object): @cherrypy.expose def default(self, *args, **kwargs): if module.get_module_option('standby_behaviour', 'redirect') == 'redirect': active_uri = module.get_active_uri() if active_uri: module.log.info("Redirecting to active '%s'", active_uri) raise cherrypy.HTTPRedirect(active_uri) else: template = """ Ceph No active ceph-mgr instance is currently running the dashboard. A failover may be in progress. Retrying in {delay} seconds... """ return template.format(delay=5) else: status = module.get_module_option('standby_error_status_code', 500) raise cherrypy.HTTPError(status, message="Keep on looking") cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {}) self.log.info("Starting engine...") cherrypy.engine.start() self.log.info("Engine started...") # Wait for shutdown event self.shutdown_event.wait() self.shutdown_event.clear() cherrypy.engine.stop() self.log.info("Engine stopped.") def shutdown(self): CherryPyConfig.shutdown(self) self.log.info("Stopping engine...") self.shutdown_event.set() self.log.info("Stopped engine...")