]> git.proxmox.com Git - ceph.git/blame_incremental - ceph/src/pybind/mgr/dashboard/module.py
update ceph source to reef 18.2.1
[ceph.git] / ceph / src / pybind / mgr / dashboard / module.py
... / ...
CommitLineData
1# -*- coding: utf-8 -*-
2"""
3ceph dashboard mgr plugin (based on CherryPy)
4"""
5import collections
6import errno
7import logging
8import os
9import socket
10import ssl
11import sys
12import tempfile
13import threading
14import time
15from typing import TYPE_CHECKING, Optional
16from urllib.parse import urlparse
17
18if TYPE_CHECKING:
19 if sys.version_info >= (3, 8):
20 from typing import Literal
21 else:
22 from typing_extensions import Literal
23
24from mgr_module import CLIReadCommand, CLIWriteCommand, HandleCommandResult, \
25 MgrModule, MgrStandbyModule, NotifyType, Option, _get_localized_key
26from mgr_util import ServerConfigException, build_url, \
27 create_self_signed_cert, get_default_addr, verify_tls_files
28
29from . import mgr
30from .controllers import Router, json_error_page
31from .grafana import push_local_dashboards
32from .services.auth import AuthManager, AuthManagerTool, JwtManager
33from .services.exception import dashboard_exception_handler
34from .services.rgw_client import configure_rgw_credentials
35from .services.sso import SSO_COMMANDS, handle_sso_command
36from .settings import Settings, handle_option_command, options_command_list, options_schema_list
37from .tools import NotificationQueue, RequestLoggingTool, TaskManager, \
38 prepare_url_prefix, str_to_bool
39
40try:
41 import cherrypy
42 from cherrypy._cptools import HandlerWrapperTool
43except ImportError:
44 # To be picked up and reported by .can_run()
45 cherrypy = None
46
47from .services.sso import load_sso_db
48
49if cherrypy is not None:
50 from .cherrypy_backports import patch_cherrypy
51 patch_cherrypy(cherrypy.__version__)
52
53# pylint: disable=wrong-import-position
54from .plugins import PLUGIN_MANAGER, debug, feature_toggles, motd # isort:skip # noqa E501 # pylint: disable=unused-import
55
56PLUGIN_MANAGER.hook.init()
57
58
59# cherrypy likes to sys.exit on error. don't let it take us down too!
60# pylint: disable=W0613
61def os_exit_noop(*args):
62 pass
63
64
65# pylint: disable=W0212
66os._exit = os_exit_noop
67
68
69logger = logging.getLogger(__name__)
70
71
72class CherryPyConfig(object):
73 """
74 Class for common server configuration done by both active and
75 standby module, especially setting up SSL.
76 """
77
78 def __init__(self):
79 self._stopping = threading.Event()
80 self._url_prefix = ""
81
82 self.cert_tmp = None
83 self.pkey_tmp = None
84
85 def shutdown(self):
86 self._stopping.set()
87
88 @property
89 def url_prefix(self):
90 return self._url_prefix
91
92 @staticmethod
93 def update_cherrypy_config(config):
94 PLUGIN_MANAGER.hook.configure_cherrypy(config=config)
95 cherrypy.config.update(config)
96
97 # pylint: disable=too-many-branches
98 def _configure(self):
99 """
100 Configure CherryPy and initialize self.url_prefix
101
102 :returns our URI
103 """
104 server_addr = self.get_localized_module_option( # type: ignore
105 'server_addr', get_default_addr())
106 use_ssl = self.get_localized_module_option('ssl', True) # type: ignore
107 if not use_ssl:
108 server_port = self.get_localized_module_option('server_port', 8080) # type: ignore
109 else:
110 server_port = self.get_localized_module_option('ssl_server_port', 8443) # type: ignore
111
112 if server_addr is None:
113 raise ServerConfigException(
114 'no server_addr configured; '
115 'try "ceph config set mgr mgr/{}/{}/server_addr <ip>"'
116 .format(self.module_name, self.get_mgr_id())) # type: ignore
117 self.log.info('server: ssl=%s host=%s port=%d', 'yes' if use_ssl else 'no', # type: ignore
118 server_addr, server_port)
119
120 # Initialize custom handlers.
121 cherrypy.tools.authenticate = AuthManagerTool()
122 self.configure_cors()
123 cherrypy.tools.plugin_hooks_filter_request = cherrypy.Tool(
124 'before_handler',
125 lambda: PLUGIN_MANAGER.hook.filter_request_before_handler(request=cherrypy.request),
126 priority=1)
127 cherrypy.tools.request_logging = RequestLoggingTool()
128 cherrypy.tools.dashboard_exception_handler = HandlerWrapperTool(dashboard_exception_handler,
129 priority=31)
130
131 cherrypy.log.access_log.propagate = False
132 cherrypy.log.error_log.propagate = False
133
134 # Apply the 'global' CherryPy configuration.
135 config = {
136 'engine.autoreload.on': False,
137 'server.socket_host': server_addr,
138 'server.socket_port': int(server_port),
139 'error_page.default': json_error_page,
140 'tools.request_logging.on': True,
141 'tools.gzip.on': True,
142 'tools.gzip.mime_types': [
143 # text/html and text/plain are the default types to compress
144 'text/html', 'text/plain',
145 # We also want JSON and JavaScript to be compressed
146 'application/json',
147 'application/*+json',
148 'application/javascript',
149 ],
150 'tools.json_in.on': True,
151 'tools.json_in.force': True,
152 'tools.plugin_hooks_filter_request.on': True,
153 }
154
155 if use_ssl:
156 # SSL initialization
157 cert = self.get_localized_store("crt") # type: ignore
158 if cert is not None:
159 self.cert_tmp = tempfile.NamedTemporaryFile()
160 self.cert_tmp.write(cert.encode('utf-8'))
161 self.cert_tmp.flush() # cert_tmp must not be gc'ed
162 cert_fname = self.cert_tmp.name
163 else:
164 cert_fname = self.get_localized_module_option('crt_file') # type: ignore
165
166 pkey = self.get_localized_store("key") # type: ignore
167 if pkey is not None:
168 self.pkey_tmp = tempfile.NamedTemporaryFile()
169 self.pkey_tmp.write(pkey.encode('utf-8'))
170 self.pkey_tmp.flush() # pkey_tmp must not be gc'ed
171 pkey_fname = self.pkey_tmp.name
172 else:
173 pkey_fname = self.get_localized_module_option('key_file') # type: ignore
174
175 verify_tls_files(cert_fname, pkey_fname)
176
177 # Create custom SSL context to disable TLS 1.0 and 1.1.
178 context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
179 context.load_cert_chain(cert_fname, pkey_fname)
180 if sys.version_info >= (3, 7):
181 if Settings.UNSAFE_TLS_v1_2:
182 context.minimum_version = ssl.TLSVersion.TLSv1_2
183 else:
184 context.minimum_version = ssl.TLSVersion.TLSv1_3
185 else:
186 if Settings.UNSAFE_TLS_v1_2:
187 context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
188 else:
189 context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ssl.OP_NO_TLSv1_2
190
191 config['server.ssl_module'] = 'builtin'
192 config['server.ssl_certificate'] = cert_fname
193 config['server.ssl_private_key'] = pkey_fname
194 config['server.ssl_context'] = context
195
196 self.update_cherrypy_config(config)
197
198 self._url_prefix = prepare_url_prefix(self.get_module_option( # type: ignore
199 'url_prefix', default=''))
200
201 if server_addr in ['::', '0.0.0.0']:
202 server_addr = self.get_mgr_ip() # type: ignore
203 base_url = build_url(
204 scheme='https' if use_ssl else 'http',
205 host=server_addr,
206 port=server_port,
207 )
208 uri = f'{base_url}{self.url_prefix}/'
209 return uri
210
211 def await_configuration(self):
212 """
213 Block until configuration is ready (i.e. all needed keys are set)
214 or self._stopping is set.
215
216 :returns URI of configured webserver
217 """
218 while not self._stopping.is_set():
219 try:
220 uri = self._configure()
221 except ServerConfigException as e:
222 self.log.info( # type: ignore
223 "Config not ready to serve, waiting: {0}".format(e)
224 )
225 # Poll until a non-errored config is present
226 self._stopping.wait(5)
227 else:
228 self.log.info("Configured CherryPy, starting engine...") # type: ignore
229 return uri
230
231 def configure_cors(self):
232 """
233 Allow CORS requests if the cross_origin_url option is set.
234 """
235 cross_origin_url = mgr.get_localized_module_option('cross_origin_url', '')
236 if cross_origin_url:
237 cherrypy.tools.CORS = cherrypy.Tool('before_handler', self.cors_tool)
238 config = {
239 'tools.CORS.on': True,
240 }
241 self.update_cherrypy_config(config)
242
243 def cors_tool(self):
244 '''
245 Handle both simple and complex CORS requests
246
247 Add CORS headers to each response. If the request is a CORS preflight
248 request swap out the default handler with a simple, single-purpose handler
249 that verifies the request and provides a valid CORS response.
250 '''
251 req_head = cherrypy.request.headers
252 resp_head = cherrypy.response.headers
253
254 # Always set response headers necessary for 'simple' CORS.
255 req_header_cross_origin_url = req_head.get('Access-Control-Allow-Origin')
256 cross_origin_urls = mgr.get_localized_module_option('cross_origin_url', '')
257 cross_origin_url_list = [url.strip() for url in cross_origin_urls.split(',')]
258 if req_header_cross_origin_url in cross_origin_url_list:
259 resp_head['Access-Control-Allow-Origin'] = req_header_cross_origin_url
260 resp_head['Access-Control-Expose-Headers'] = 'GET, POST'
261 resp_head['Access-Control-Allow-Credentials'] = 'true'
262
263 # Non-simple CORS preflight request; short-circuit the normal handler.
264 if cherrypy.request.method == 'OPTIONS':
265 req_header_origin_url = req_head.get('Origin')
266 if req_header_origin_url in cross_origin_url_list:
267 resp_head['Access-Control-Allow-Origin'] = req_header_origin_url
268 ac_method = req_head.get('Access-Control-Request-Method', None)
269
270 allowed_methods = ['GET', 'POST', 'PUT']
271 allowed_headers = [
272 'Content-Type',
273 'Authorization',
274 'Accept',
275 'Access-Control-Allow-Origin'
276 ]
277
278 if ac_method and ac_method in allowed_methods:
279 resp_head['Access-Control-Allow-Methods'] = ', '.join(allowed_methods)
280 resp_head['Access-Control-Allow-Headers'] = ', '.join(allowed_headers)
281
282 resp_head['Connection'] = 'keep-alive'
283 resp_head['Access-Control-Max-Age'] = '3600'
284
285 # CORS requests should short-circuit the other tools.
286 cherrypy.response.body = ''.encode('utf8')
287 cherrypy.response.status = 200
288 cherrypy.serving.request.handler = None
289
290 # Needed to avoid the auth_tool check.
291 if cherrypy.request.config.get('tools.sessions.on', False):
292 cherrypy.session['token'] = True
293 return True
294
295
296if TYPE_CHECKING:
297 SslConfigKey = Literal['crt', 'key']
298
299
300class Module(MgrModule, CherryPyConfig):
301 """
302 dashboard module entrypoint
303 """
304
305 COMMANDS = [
306 {
307 'cmd': 'dashboard set-jwt-token-ttl '
308 'name=seconds,type=CephInt',
309 'desc': 'Set the JWT token TTL in seconds',
310 'perm': 'w'
311 },
312 {
313 'cmd': 'dashboard get-jwt-token-ttl',
314 'desc': 'Get the JWT token TTL in seconds',
315 'perm': 'r'
316 },
317 {
318 "cmd": "dashboard create-self-signed-cert",
319 "desc": "Create self signed certificate",
320 "perm": "w"
321 },
322 {
323 "cmd": "dashboard grafana dashboards update",
324 "desc": "Push dashboards to Grafana",
325 "perm": "w",
326 },
327 ]
328 COMMANDS.extend(options_command_list())
329 COMMANDS.extend(SSO_COMMANDS)
330 PLUGIN_MANAGER.hook.register_commands()
331
332 MODULE_OPTIONS = [
333 Option(name='server_addr', type='str', default=get_default_addr()),
334 Option(name='server_port', type='int', default=8080),
335 Option(name='ssl_server_port', type='int', default=8443),
336 Option(name='jwt_token_ttl', type='int', default=28800),
337 Option(name='url_prefix', type='str', default=''),
338 Option(name='key_file', type='str', default=''),
339 Option(name='crt_file', type='str', default=''),
340 Option(name='ssl', type='bool', default=True),
341 Option(name='standby_behaviour', type='str', default='redirect',
342 enum_allowed=['redirect', 'error']),
343 Option(name='standby_error_status_code', type='int', default=500,
344 min=400, max=599),
345 Option(name='redirect_resolve_ip_addr', type='bool', default=False),
346 Option(name='cross_origin_url', type='str', default=''),
347 ]
348 MODULE_OPTIONS.extend(options_schema_list())
349 for options in PLUGIN_MANAGER.hook.get_options() or []:
350 MODULE_OPTIONS.extend(options)
351
352 NOTIFY_TYPES = [NotifyType.clog]
353
354 __pool_stats = collections.defaultdict(lambda: collections.defaultdict(
355 lambda: collections.deque(maxlen=10))) # type: dict
356
357 def __init__(self, *args, **kwargs):
358 super(Module, self).__init__(*args, **kwargs)
359 CherryPyConfig.__init__(self)
360
361 mgr.init(self)
362
363 self._stopping = threading.Event()
364 self.shutdown_event = threading.Event()
365 self.ACCESS_CTRL_DB = None
366 self.SSO_DB = None
367 self.health_checks = {}
368
369 @classmethod
370 def can_run(cls):
371 if cherrypy is None:
372 return False, "Missing dependency: cherrypy"
373
374 if not os.path.exists(cls.get_frontend_path()):
375 return False, ("Frontend assets not found at '{}': incomplete build?"
376 .format(cls.get_frontend_path()))
377
378 return True, ""
379
380 @classmethod
381 def get_frontend_path(cls):
382 current_dir = os.path.dirname(os.path.abspath(__file__))
383 path = os.path.join(current_dir, 'frontend/dist')
384 if os.path.exists(path):
385 return path
386 else:
387 path = os.path.join(current_dir,
388 '../../../../build',
389 'src/pybind/mgr/dashboard',
390 'frontend/dist')
391 return os.path.abspath(path)
392
393 def serve(self):
394
395 if 'COVERAGE_ENABLED' in os.environ:
396 import coverage
397 __cov = coverage.Coverage(config_file="{}/.coveragerc"
398 .format(os.path.dirname(__file__)),
399 data_suffix=True)
400 __cov.start()
401 cherrypy.engine.subscribe('after_request', __cov.save)
402 cherrypy.engine.subscribe('stop', __cov.stop)
403
404 AuthManager.initialize()
405 load_sso_db()
406
407 uri = self.await_configuration()
408 if uri is None:
409 # We were shut down while waiting
410 return
411
412 # Publish the URI that others may use to access the service we're
413 # about to start serving
414 self.set_uri(uri)
415
416 mapper, parent_urls = Router.generate_routes(self.url_prefix)
417
418 config = {}
419 for purl in parent_urls:
420 config[purl] = {
421 'request.dispatch': mapper
422 }
423
424 cherrypy.tree.mount(None, config=config)
425
426 PLUGIN_MANAGER.hook.setup()
427
428 cherrypy.engine.start()
429 NotificationQueue.start_queue()
430 TaskManager.init()
431 logger.info('Engine started.')
432 update_dashboards = str_to_bool(
433 self.get_module_option('GRAFANA_UPDATE_DASHBOARDS', 'False'))
434 if update_dashboards:
435 logger.info('Starting Grafana dashboard task')
436 TaskManager.run(
437 'grafana/dashboards/update',
438 {},
439 push_local_dashboards,
440 kwargs=dict(tries=10, sleep=60),
441 )
442 # wait for the shutdown event
443 self.shutdown_event.wait()
444 self.shutdown_event.clear()
445 NotificationQueue.stop()
446 cherrypy.engine.stop()
447 logger.info('Engine stopped')
448
449 def shutdown(self):
450 super(Module, self).shutdown()
451 CherryPyConfig.shutdown(self)
452 logger.info('Stopping engine...')
453 self.shutdown_event.set()
454
455 def _set_ssl_item(self, item_label: str, item_key: 'SslConfigKey' = 'crt',
456 mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
457 if inbuf is None:
458 return -errno.EINVAL, '', f'Please specify the {item_label} with "-i" option'
459
460 if mgr_id is not None:
461 self.set_store(_get_localized_key(mgr_id, item_key), inbuf)
462 else:
463 self.set_store(item_key, inbuf)
464 return 0, f'SSL {item_label} updated', ''
465
466 @CLIWriteCommand("dashboard set-ssl-certificate")
467 def set_ssl_certificate(self, mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
468 return self._set_ssl_item('certificate', 'crt', mgr_id, inbuf)
469
470 @CLIWriteCommand("dashboard set-ssl-certificate-key")
471 def set_ssl_certificate_key(self, mgr_id: Optional[str] = None, inbuf: Optional[str] = None):
472 return self._set_ssl_item('certificate key', 'key', mgr_id, inbuf)
473
474 @CLIWriteCommand("dashboard create-self-signed-cert")
475 def set_mgr_created_self_signed_cert(self):
476 cert, pkey = create_self_signed_cert('IT', 'ceph-dashboard')
477 result = HandleCommandResult(*self.set_ssl_certificate(inbuf=cert))
478 if result.retval != 0:
479 return result
480
481 result = HandleCommandResult(*self.set_ssl_certificate_key(inbuf=pkey))
482 if result.retval != 0:
483 return result
484 return 0, 'Self-signed certificate created', ''
485
486 @CLIWriteCommand("dashboard set-rgw-credentials")
487 def set_rgw_credentials(self):
488 try:
489 configure_rgw_credentials()
490 except Exception as error:
491 return -errno.EINVAL, '', str(error)
492
493 return 0, 'RGW credentials configured', ''
494
495 @CLIWriteCommand("dashboard set-login-banner")
496 def set_login_banner(self, inbuf: str):
497 '''
498 Set the custom login banner read from -i <file>
499 '''
500 item_label = 'login banner file'
501 if inbuf is None:
502 return HandleCommandResult(
503 -errno.EINVAL,
504 stderr=f'Please specify the {item_label} with "-i" option'
505 )
506 mgr.set_store('custom_login_banner', inbuf)
507 return HandleCommandResult(stdout=f'{item_label} added')
508
509 @CLIReadCommand("dashboard get-login-banner")
510 def get_login_banner(self):
511 '''
512 Get the custom login banner text
513 '''
514 banner_text = mgr.get_store('custom_login_banner')
515 if banner_text is None:
516 return HandleCommandResult(stdout='No login banner set')
517 else:
518 return HandleCommandResult(stdout=banner_text)
519
520 @CLIWriteCommand("dashboard unset-login-banner")
521 def unset_login_banner(self):
522 '''
523 Unset the custom login banner
524 '''
525 mgr.set_store('custom_login_banner', None)
526 return HandleCommandResult(stdout='Login banner removed')
527
528 def handle_command(self, inbuf, cmd):
529 # pylint: disable=too-many-return-statements
530 res = handle_option_command(cmd, inbuf)
531 if res[0] != -errno.ENOSYS:
532 return res
533 res = handle_sso_command(cmd)
534 if res[0] != -errno.ENOSYS:
535 return res
536 if cmd['prefix'] == 'dashboard set-jwt-token-ttl':
537 self.set_module_option('jwt_token_ttl', str(cmd['seconds']))
538 return 0, 'JWT token TTL updated', ''
539 if cmd['prefix'] == 'dashboard get-jwt-token-ttl':
540 ttl = self.get_module_option('jwt_token_ttl', JwtManager.JWT_TOKEN_TTL)
541 return 0, str(ttl), ''
542 if cmd['prefix'] == 'dashboard grafana dashboards update':
543 push_local_dashboards()
544 return 0, 'Grafana dashboards updated', ''
545
546 return (-errno.EINVAL, '', 'Command not found \'{0}\''
547 .format(cmd['prefix']))
548
549 def notify(self, notify_type: NotifyType, notify_id):
550 NotificationQueue.new_notification(str(notify_type), notify_id)
551
552 def get_updated_pool_stats(self):
553 df = self.get('df')
554 pool_stats = {p['id']: p['stats'] for p in df['pools']}
555 now = time.time()
556 for pool_id, stats in pool_stats.items():
557 for stat_name, stat_val in stats.items():
558 self.__pool_stats[pool_id][stat_name].append((now, stat_val))
559
560 return self.__pool_stats
561
562 def config_notify(self):
563 """
564 This method is called whenever one of our config options is changed.
565 """
566 PLUGIN_MANAGER.hook.config_notify()
567
568 def refresh_health_checks(self):
569 self.set_health_checks(self.health_checks)
570
571
572class StandbyModule(MgrStandbyModule, CherryPyConfig):
573 def __init__(self, *args, **kwargs):
574 super(StandbyModule, self).__init__(*args, **kwargs)
575 CherryPyConfig.__init__(self)
576 self.shutdown_event = threading.Event()
577
578 # We can set the global mgr instance to ourselves even though
579 # we're just a standby, because it's enough for logging.
580 mgr.init(self)
581
582 def serve(self):
583 uri = self.await_configuration()
584 if uri is None:
585 # We were shut down while waiting
586 return
587
588 module = self
589
590 class Root(object):
591 @cherrypy.expose
592 def default(self, *args, **kwargs):
593 if module.get_module_option('standby_behaviour', 'redirect') == 'redirect':
594 active_uri = module.get_active_uri()
595
596 if cherrypy.request.path_info.startswith('/api/prometheus_receiver'):
597 module.log.debug("Suppressed redirecting alert to active '%s'",
598 active_uri)
599 cherrypy.response.status = 204
600 return None
601
602 if active_uri:
603 if module.get_module_option('redirect_resolve_ip_addr'):
604 p_result = urlparse(active_uri)
605 hostname = str(p_result.hostname)
606 fqdn_netloc = p_result.netloc.replace(
607 hostname, socket.getfqdn(hostname))
608 active_uri = p_result._replace(netloc=fqdn_netloc).geturl()
609
610 module.log.info("Redirecting to active '%s'", active_uri)
611 raise cherrypy.HTTPRedirect(active_uri)
612 else:
613 template = """
614 <html>
615 <!-- Note: this is only displayed when the standby
616 does not know an active URI to redirect to, otherwise
617 a simple redirect is returned instead -->
618 <head>
619 <title>Ceph</title>
620 <meta http-equiv="refresh" content="{delay}">
621 </head>
622 <body>
623 No active ceph-mgr instance is currently running
624 the dashboard. A failover may be in progress.
625 Retrying in {delay} seconds...
626 </body>
627 </html>
628 """
629 return template.format(delay=5)
630 else:
631 status = module.get_module_option('standby_error_status_code', 500)
632 raise cherrypy.HTTPError(status, message="Keep on looking")
633
634 cherrypy.tree.mount(Root(), "{}/".format(self.url_prefix), {})
635 self.log.info("Starting engine...")
636 cherrypy.engine.start()
637 self.log.info("Engine started...")
638 # Wait for shutdown event
639 self.shutdown_event.wait()
640 self.shutdown_event.clear()
641 cherrypy.engine.stop()
642 self.log.info("Engine stopped.")
643
644 def shutdown(self):
645 CherryPyConfig.shutdown(self)
646
647 self.log.info("Stopping engine...")
648 self.shutdown_event.set()
649 self.log.info("Stopped engine...")