]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/mgr/dashboard/module.py
buildsys: use download.ceph.com to download source tar ball
[ceph.git] / ceph / src / pybind / mgr / dashboard / module.py
CommitLineData
11fdf7f2 1# -*- coding: utf-8 -*-
31f18b77 2"""
11fdf7f2 3ceph dashboard mgr plugin (based on CherryPy)
31f18b77 4"""
11fdf7f2 5from __future__ import absolute_import
31f18b77 6
31f18b77 7import collections
11fdf7f2
TL
8import errno
9from distutils.version import StrictVersion
31f18b77 10import os
3efd9988 11import socket
11fdf7f2
TL
12import tempfile
13import threading
14import time
15from uuid import uuid4
16from OpenSSL import crypto
17from mgr_module import MgrModule, MgrStandbyModule, Option
18
19try:
20 import cherrypy
21 from cherrypy._cptools import HandlerWrapperTool
22except ImportError:
23 # To be picked up and reported by .can_run()
24 cherrypy = None
25
26from .services.sso import load_sso_db
27
28# The SSL code in CherryPy 3.5.0 is buggy. It was fixed long ago,
29# but 3.5.0 is still shipping in major linux distributions
30# (Fedora 27, Ubuntu Xenial), so we must monkey patch it to get SSL working.
31if cherrypy is not None:
32 v = StrictVersion(cherrypy.__version__)
33 # It was fixed in 3.7.0. Exact lower bound version is probably earlier,
34 # but 3.5.0 is what this monkey patch is tested on.
35 if StrictVersion("3.5.0") <= v < StrictVersion("3.7.0"):
36 from cherrypy.wsgiserver.wsgiserver2 import HTTPConnection,\
37 CP_fileobject
31f18b77 38
11fdf7f2
TL
39 def fixed_init(hc_self, server, sock, makefile=CP_fileobject):
40 hc_self.server = server
41 hc_self.socket = sock
42 hc_self.rfile = makefile(sock, "rb", hc_self.rbufsize)
43 hc_self.wfile = makefile(sock, "wb", hc_self.wbufsize)
44 hc_self.requests_seen = 0
31f18b77 45
11fdf7f2 46 HTTPConnection.__init__ = fixed_init
31f18b77 47
a8e16298
TL
48# When the CherryPy server in 3.2.2 (and later) starts it attempts to verify
49# that the ports its listening on are in fact bound. When using the any address
50# "::" it tries both ipv4 and ipv6, and in some environments (e.g. kubernetes)
51# ipv6 isn't yet configured / supported and CherryPy throws an uncaught
52# exception.
53if cherrypy is not None:
54 v = StrictVersion(cherrypy.__version__)
55 # the issue was fixed in 3.2.3. it's present in 3.2.2 (current version on
56 # centos:7) and back to at least 3.0.0.
57 if StrictVersion("3.1.2") <= v < StrictVersion("3.2.3"):
58 # https://github.com/cherrypy/cherrypy/issues/1100
59 from cherrypy.process import servers
60 servers.wait_for_occupied_port = lambda host, port: None
61
11fdf7f2
TL
62if 'COVERAGE_ENABLED' in os.environ:
63 import coverage
64 __cov = coverage.Coverage(config_file="{}/.coveragerc".format(os.path.dirname(__file__)),
65 data_suffix=True)
31f18b77 66
11fdf7f2
TL
67 cherrypy.engine.subscribe('start', __cov.start)
68 cherrypy.engine.subscribe('after_request', __cov.save)
69 cherrypy.engine.subscribe('stop', __cov.stop)
31f18b77 70
11fdf7f2
TL
71# pylint: disable=wrong-import-position
72from . import logger, mgr
73from .controllers import generate_routes, json_error_page
74from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \
75 prepare_url_prefix
76from .services.auth import AuthManager, AuthManagerTool, JwtManager
77from .services.sso import SSO_COMMANDS, \
78 handle_sso_command
79from .services.exception import dashboard_exception_handler
80from .settings import options_command_list, options_schema_list, \
81 handle_option_command
31f18b77 82
11fdf7f2
TL
83from .plugins import PLUGIN_MANAGER
84from .plugins import feature_toggles # noqa # pylint: disable=unused-import
3efd9988
FG
85
86
11fdf7f2
TL
87# cherrypy likes to sys.exit on error. don't let it take us down too!
88# pylint: disable=W0613
89def os_exit_noop(*args):
90 pass
3efd9988 91
b32b8144 92
11fdf7f2
TL
93# pylint: disable=W0212
94os._exit = os_exit_noop
b32b8144 95
3efd9988 96
11fdf7f2
TL
97class ServerConfigException(Exception):
98 pass
3efd9988 99
3efd9988 100
11fdf7f2
TL
101class CherryPyConfig(object):
102 """
103 Class for common server configuration done by both active and
104 standby module, especially setting up SSL.
105 """
106 def __init__(self):
107 self._stopping = threading.Event()
108 self._url_prefix = ""
3efd9988 109
11fdf7f2
TL
110 self.cert_tmp = None
111 self.pkey_tmp = None
3efd9988
FG
112
113 def shutdown(self):
11fdf7f2 114 self._stopping.set()
3efd9988 115
31f18b77 116 @property
11fdf7f2
TL
117 def url_prefix(self):
118 return self._url_prefix
31f18b77 119
11fdf7f2 120 def _configure(self):
31f18b77 121 """
11fdf7f2
TL
122 Configure CherryPy and initialize self.url_prefix
123
124 :returns our URI
31f18b77 125 """
11fdf7f2
TL
126 server_addr = self.get_localized_module_option('server_addr', '::')
127 ssl = self.get_localized_module_option('ssl', True)
128 if not ssl:
129 server_port = self.get_localized_module_option('server_port', 8080)
31f18b77 130 else:
11fdf7f2 131 server_port = self.get_localized_module_option('ssl_server_port', 8443)
31f18b77 132
11fdf7f2
TL
133 if server_addr is None:
134 raise ServerConfigException(
135 'no server_addr configured; '
136 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
137 .format(self.module_name, self.get_mgr_id()))
138 self.log.info('server: ssl=%s host=%s port=%d', 'yes' if ssl else 'no',
139 server_addr, server_port)
140
141 # Initialize custom handlers.
142 cherrypy.tools.authenticate = AuthManagerTool()
143 cherrypy.tools.plugin_hooks = cherrypy.Tool(
144 'before_handler',
145 lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request),
146 priority=10)
147 cherrypy.tools.request_logging = RequestLoggingTool()
148 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
149 priority=31)
150
151 # Apply the 'global' CherryPy configuration.
152 config = {
153 'engine.autoreload.on': False,
154 'server.socket_host': server_addr,
155 'server.socket_port': int(server_port),
156 'error_page.default': json_error_page,
157 'tools.request_logging.on': True,
158 'tools.gzip.on': True,
159 'tools.gzip.mime_types': [
160 # text/html and text/plain are the default types to compress
161 'text/html', 'text/plain',
162 # We also want JSON and JavaScript to be compressed
163 'application/json',
164 'application/javascript',
165 ],
166 'tools.json_in.on': True,
167 'tools.json_in.force': False,
168 'tools.plugin_hooks.on': True,
31f18b77
FG
169 }
170
11fdf7f2
TL
171 if ssl:
172 # SSL initialization
173 cert = self.get_store("crt")
174 if cert is not None:
175 self.cert_tmp = tempfile.NamedTemporaryFile()
176 self.cert_tmp.write(cert.encode('utf-8'))
177 self.cert_tmp.flush() # cert_tmp must not be gc'ed
178 cert_fname = self.cert_tmp.name
b32b8144 179 else:
11fdf7f2
TL
180 cert_fname = self.get_localized_module_option('crt_file')
181
182 pkey = self.get_store("key")
183 if pkey is not None:
184 self.pkey_tmp = tempfile.NamedTemporaryFile()
185 self.pkey_tmp.write(pkey.encode('utf-8'))
186 self.pkey_tmp.flush() # pkey_tmp must not be gc'ed
187 pkey_fname = self.pkey_tmp.name
188 else:
189 pkey_fname = self.get_localized_module_option('key_file')
c07f9fc5 190
11fdf7f2
TL
191 if not cert_fname or not pkey_fname:
192 raise ServerConfigException('no certificate configured')
193 if not os.path.isfile(cert_fname):
194 raise ServerConfigException('certificate %s does not exist' % cert_fname)
195 if not os.path.isfile(pkey_fname):
196 raise ServerConfigException('private key %s does not exist' % pkey_fname)
c07f9fc5 197
11fdf7f2
TL
198 config['server.ssl_module'] = 'builtin'
199 config['server.ssl_certificate'] = cert_fname
200 config['server.ssl_private_key'] = pkey_fname
31f18b77 201
11fdf7f2 202 cherrypy.config.update(config)
31f18b77 203
11fdf7f2
TL
204 self._url_prefix = prepare_url_prefix(self.get_module_option('url_prefix',
205 default=''))
31f18b77 206
11fdf7f2
TL
207 uri = "{0}://{1}:{2}{3}/".format(
208 'https' if ssl else 'http',
209 socket.getfqdn() if server_addr == "::" else server_addr,
210 server_port,
211 self.url_prefix
212 )
1adf2230 213
11fdf7f2 214 return uri
1adf2230 215
11fdf7f2
TL
216 def await_configuration(self):
217 """
218 Block until configuration is ready (i.e. all needed keys are set)
219 or self._stopping is set.
31f18b77 220
11fdf7f2
TL
221 :returns URI of configured webserver
222 """
223 while not self._stopping.is_set():
224 try:
225 uri = self._configure()
226 except ServerConfigException as e:
227 self.log.info("Config not ready to serve, waiting: {0}".format(
228 e
229 ))
230 # Poll until a non-errored config is present
231 self._stopping.wait(5)
232 else:
233 self.log.info("Configured CherryPy, starting engine...")
234 return uri
31f18b77 235
31f18b77 236
11fdf7f2
TL
237class Module(MgrModule, CherryPyConfig):
238 """
239 dashboard module entrypoint
240 """
31f18b77 241
11fdf7f2
TL
242 COMMANDS = [
243 {
244 'cmd': 'dashboard set-jwt-token-ttl '
245 'name=seconds,type=CephInt',
246 'desc': 'Set the JWT token TTL in seconds',
247 'perm': 'w'
248 },
249 {
250 'cmd': 'dashboard get-jwt-token-ttl',
251 'desc': 'Get the JWT token TTL in seconds',
252 'perm': 'r'
253 },
254 {
255 "cmd": "dashboard create-self-signed-cert",
256 "desc": "Create self signed certificate",
257 "perm": "w"
258 },
259 ]
260 COMMANDS.extend(options_command_list())
261 COMMANDS.extend(SSO_COMMANDS)
262 PLUGIN_MANAGER.hook.register_commands()
263
264 MODULE_OPTIONS = [
265 Option(name='server_addr', type='str', default='::'),
266 Option(name='server_port', type='int', default=8080),
267 Option(name='ssl_server_port', type='int', default=8443),
268 Option(name='jwt_token_ttl', type='int', default=28800),
269 Option(name='password', type='str', default=''),
270 Option(name='url_prefix', type='str', default=''),
271 Option(name='username', type='str', default=''),
272 Option(name='key_file', type='str', default=''),
273 Option(name='crt_file', type='str', default=''),
274 Option(name='ssl', type='bool', default=True)
275 ]
276 MODULE_OPTIONS.extend(options_schema_list())
277 for options in PLUGIN_MANAGER.hook.get_options() or []:
278 MODULE_OPTIONS.extend(options)
279
280 __pool_stats = collections.defaultdict(lambda: collections.defaultdict(
281 lambda: collections.deque(maxlen=10)))
31f18b77 282
11fdf7f2
TL
283 def __init__(self, *args, **kwargs):
284 super(Module, self).__init__(*args, **kwargs)
285 CherryPyConfig.__init__(self)
31f18b77 286
11fdf7f2 287 mgr.init(self)
31f18b77 288
11fdf7f2
TL
289 self._stopping = threading.Event()
290 self.shutdown_event = threading.Event()
31f18b77 291
11fdf7f2
TL
292 self.ACCESS_CTRL_DB = None
293 self.SSO_DB = None
31f18b77 294
11fdf7f2
TL
295 @classmethod
296 def can_run(cls):
297 if cherrypy is None:
298 return False, "Missing dependency: cherrypy"
31f18b77 299
11fdf7f2
TL
300 if not os.path.exists(cls.get_frontend_path()):
301 return False, "Frontend assets not found: incomplete build?"
31f18b77 302
11fdf7f2 303 return True, ""
31f18b77 304
11fdf7f2
TL
305 @classmethod
306 def get_frontend_path(cls):
307 current_dir = os.path.dirname(os.path.abspath(__file__))
308 return os.path.join(current_dir, 'frontend/dist')
c07f9fc5 309
11fdf7f2
TL
310 def serve(self):
311 AuthManager.initialize()
312 load_sso_db()
31f18b77 313
11fdf7f2
TL
314 uri = self.await_configuration()
315 if uri is None:
316 # We were shut down while waiting
317 return
3efd9988
FG
318
319 # Publish the URI that others may use to access the service we're
320 # about to start serving
11fdf7f2 321 self.set_uri(uri)
c07f9fc5 322
11fdf7f2 323 mapper, parent_urls = generate_routes(self.url_prefix)
c07f9fc5 324
11fdf7f2
TL
325 config = {
326 self.url_prefix or '/': {
327 'tools.staticdir.on': True,
328 'tools.staticdir.dir': self.get_frontend_path(),
329 'tools.staticdir.index': 'index.html'
330 }
331 }
332 for purl in parent_urls:
333 config[purl] = {
334 'request.dispatch': mapper
335 }
336 cherrypy.tree.mount(None, config=config)
c07f9fc5 337
11fdf7f2 338 PLUGIN_MANAGER.hook.setup()
c07f9fc5 339
11fdf7f2
TL
340 cherrypy.engine.start()
341 NotificationQueue.start_queue()
342 TaskManager.init()
343 logger.info('Engine started.')
344 # wait for the shutdown event
345 self.shutdown_event.wait()
346 self.shutdown_event.clear()
347 NotificationQueue.stop()
348 cherrypy.engine.stop()
349 logger.info('Engine stopped')
c07f9fc5 350
11fdf7f2
TL
351 def shutdown(self):
352 super(Module, self).shutdown()
353 CherryPyConfig.shutdown(self)
354 logger.info('Stopping engine...')
355 self.shutdown_event.set()
356
357 def handle_command(self, inbuf, cmd):
358 # pylint: disable=too-many-return-statements
359 res = handle_option_command(cmd)
360 if res[0] != -errno.ENOSYS:
361 return res
362 res = handle_sso_command(cmd)
363 if res[0] != -errno.ENOSYS:
364 return res
365 if cmd['prefix'] == 'dashboard set-jwt-token-ttl':
366 self.set_module_option('jwt_token_ttl', str(cmd['seconds']))
367 return 0, 'JWT token TTL updated', ''
368 if cmd['prefix'] == 'dashboard get-jwt-token-ttl':
369 ttl = self.get_module_option('jwt_token_ttl', JwtManager.JWT_TOKEN_TTL)
370 return 0, str(ttl), ''
371 if cmd['prefix'] == 'dashboard create-self-signed-cert':
372 self.create_self_signed_cert()
373 return 0, 'Self-signed certificate created', ''
374
375 return (-errno.EINVAL, '', 'Command not found \'{0}\''
376 .format(cmd['prefix']))
377
378 def create_self_signed_cert(self):
379 # create a key pair
380 pkey = crypto.PKey()
381 pkey.generate_key(crypto.TYPE_RSA, 2048)
382
383 # create a self-signed cert
384 cert = crypto.X509()
385 cert.get_subject().O = "IT"
386 cert.get_subject().CN = "ceph-dashboard"
387 cert.set_serial_number(int(uuid4()))
388 cert.gmtime_adj_notBefore(0)
389 cert.gmtime_adj_notAfter(10*365*24*60*60)
390 cert.set_issuer(cert.get_subject())
391 cert.set_pubkey(pkey)
392 cert.sign(pkey, 'sha512')
393
394 cert = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
395 self.set_store('crt', cert.decode('utf-8'))
396
397 pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)
398 self.set_store('key', pkey.decode('utf-8'))
399
400 def notify(self, notify_type, notify_id):
401 NotificationQueue.new_notification(notify_type, notify_id)
402
403 def get_updated_pool_stats(self):
404 df = self.get('df')
405 pool_stats = {p['id']: p['stats'] for p in df['pools']}
406 now = time.time()
407 for pool_id, stats in pool_stats.items():
408 for stat_name, stat_val in stats.items():
409 self.__pool_stats[pool_id][stat_name].append((now, stat_val))
c07f9fc5 410
11fdf7f2 411 return self.__pool_stats
c07f9fc5 412
c07f9fc5 413
11fdf7f2
TL
414class StandbyModule(MgrStandbyModule, CherryPyConfig):
415 def __init__(self, *args, **kwargs):
416 super(StandbyModule, self).__init__(*args, **kwargs)
417 CherryPyConfig.__init__(self)
418 self.shutdown_event = threading.Event()
c07f9fc5 419
11fdf7f2
TL
420 # We can set the global mgr instance to ourselves even though
421 # we're just a standby, because it's enough for logging.
422 mgr.init(self)
c07f9fc5 423
11fdf7f2
TL
424 def serve(self):
425 uri = self.await_configuration()
426 if uri is None:
427 # We were shut down while waiting
428 return
c07f9fc5 429
11fdf7f2 430 module = self
c07f9fc5 431
11fdf7f2 432 class Root(object):
c07f9fc5
FG
433 @cherrypy.expose
434 def index(self):
11fdf7f2
TL
435 active_uri = module.get_active_uri()
436 if active_uri:
437 module.log.info("Redirecting to active '%s'", active_uri)
438 raise cherrypy.HTTPRedirect(active_uri)
439 else:
440 template = """
441 <html>
442 <!-- Note: this is only displayed when the standby
443 does not know an active URI to redirect to, otherwise
444 a simple redirect is returned instead -->
445 <head>
446 <title>Ceph</title>
447 <meta http-equiv="refresh" content="{delay}">
448 </head>
449 <body>
450 No active ceph-mgr instance is currently running
451 the dashboard. A failover may be in progress.
452 Retrying in {delay} seconds...
453 </body>
454 </html>
455 """
456 return template.format(delay=5)
457
458 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
459 self.log.info("Starting engine...")
31f18b77 460 cherrypy.engine.start()
11fdf7f2
TL
461 self.log.info("Engine started...")
462 # Wait for shutdown event
463 self.shutdown_event.wait()
464 self.shutdown_event.clear()
465 cherrypy.engine.stop()
466 self.log.info("Engine stopped.")
467
468 def shutdown(self):
469 CherryPyConfig.shutdown(self)
470
471 self.log.info("Stopping engine...")
472 self.shutdown_event.set()
473 self.log.info("Stopped engine...")