##// END OF EJS Templates
make content_security_policy a property...
Min RK -
Show More
@@ -1,537 +1,553 b''
1 1 """Base Tornado handlers for the notebook server."""
2 2
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import functools
7 7 import json
8 8 import logging
9 9 import os
10 10 import re
11 11 import sys
12 12 import traceback
13 13 try:
14 14 # py3
15 15 from http.client import responses
16 16 except ImportError:
17 17 from httplib import responses
18 18
19 19 from jinja2 import TemplateNotFound
20 20 from tornado import web
21 21
22 22 from tornado import gen
23 23 from tornado.log import app_log
24 24
25 25
26 26 import IPython
27 27 from IPython.utils.sysinfo import get_sys_info
28 28
29 29 from IPython.config import Application
30 30 from IPython.utils.path import filefind
31 31 from IPython.utils.py3compat import string_types
32 32 from IPython.html.utils import is_hidden, url_path_join, url_escape
33 33
34 34 from IPython.html.services.security import csp_report_uri
35 35
36 36 #-----------------------------------------------------------------------------
37 37 # Top-level handlers
38 38 #-----------------------------------------------------------------------------
39 39 non_alphanum = re.compile(r'[^A-Za-z0-9]')
40 40
41 41 sys_info = json.dumps(get_sys_info())
42 42
43 43 class AuthenticatedHandler(web.RequestHandler):
44 44 """A RequestHandler with an authenticated user."""
45
46 @property
47 def content_security_policy(self):
48 """The default Content-Security-Policy header
49
50 Can be overridden by defining Content-Security-Policy in settings['headers']
51 """
52 return '; '.join([
53 "frame-ancestors 'self'",
54 # Make sure the report-uri is relative to the base_url
55 "report-uri " + url_path_join(self.base_url, csp_report_uri),
56 ])
45 57
46 58 def set_default_headers(self):
47 59 headers = self.settings.get('headers', {})
48 60
49 61 if "Content-Security-Policy" not in headers:
50 headers["Content-Security-Policy"] = (
51 "frame-ancestors 'self'; "
52 # Make sure the report-uri is relative to the base_url
53 "report-uri " + url_path_join(self.base_url, csp_report_uri) + ";"
54 )
62 headers["Content-Security-Policy"] = self.content_security_policy
55 63
56 64 # Allow for overriding headers
57 65 for header_name,value in headers.items() :
58 66 try:
59 67 self.set_header(header_name, value)
60 68 except Exception as e:
61 69 # tornado raise Exception (not a subclass)
62 70 # if method is unsupported (websocket and Access-Control-Allow-Origin
63 71 # for example, so just ignore)
64 72 self.log.debug(e)
65 73
66 74 def clear_login_cookie(self):
67 75 self.clear_cookie(self.cookie_name)
68 76
69 77 def get_current_user(self):
70 78 if self.login_handler is None:
71 79 return 'anonymous'
72 80 return self.login_handler.get_user(self)
73 81
74 82 @property
75 83 def cookie_name(self):
76 84 default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
77 85 self.request.host
78 86 ))
79 87 return self.settings.get('cookie_name', default_cookie_name)
80 88
81 89 @property
82 90 def logged_in(self):
83 91 """Is a user currently logged in?"""
84 92 user = self.get_current_user()
85 93 return (user and not user == 'anonymous')
86 94
87 95 @property
88 96 def login_handler(self):
89 97 """Return the login handler for this application, if any."""
90 98 return self.settings.get('login_handler_class', None)
91 99
92 100 @property
93 101 def login_available(self):
94 102 """May a user proceed to log in?
95 103
96 104 This returns True if login capability is available, irrespective of
97 105 whether the user is already logged in or not.
98 106
99 107 """
100 108 if self.login_handler is None:
101 109 return False
102 110 return bool(self.login_handler.login_available(self.settings))
103 111
104 112
105 113 class IPythonHandler(AuthenticatedHandler):
106 114 """IPython-specific extensions to authenticated handling
107 115
108 116 Mostly property shortcuts to IPython-specific settings.
109 117 """
110 118
111 119 @property
112 120 def config(self):
113 121 return self.settings.get('config', None)
114 122
115 123 @property
116 124 def log(self):
117 125 """use the IPython log by default, falling back on tornado's logger"""
118 126 if Application.initialized():
119 127 return Application.instance().log
120 128 else:
121 129 return app_log
122 130
123 131 @property
124 132 def jinja_template_vars(self):
125 133 """User-supplied values to supply to jinja templates."""
126 134 return self.settings.get('jinja_template_vars', {})
127 135
128 136 #---------------------------------------------------------------
129 137 # URLs
130 138 #---------------------------------------------------------------
131 139
132 140 @property
133 141 def version_hash(self):
134 142 """The version hash to use for cache hints for static files"""
135 143 return self.settings.get('version_hash', '')
136 144
137 145 @property
138 146 def mathjax_url(self):
139 147 return self.settings.get('mathjax_url', '')
140 148
141 149 @property
142 150 def base_url(self):
143 151 return self.settings.get('base_url', '/')
144 152
145 153 @property
146 154 def default_url(self):
147 155 return self.settings.get('default_url', '')
148 156
149 157 @property
150 158 def ws_url(self):
151 159 return self.settings.get('websocket_url', '')
152 160
153 161 @property
154 162 def contents_js_source(self):
155 163 self.log.debug("Using contents: %s", self.settings.get('contents_js_source',
156 164 'services/contents'))
157 165 return self.settings.get('contents_js_source', 'services/contents')
158 166
159 167 #---------------------------------------------------------------
160 168 # Manager objects
161 169 #---------------------------------------------------------------
162 170
163 171 @property
164 172 def kernel_manager(self):
165 173 return self.settings['kernel_manager']
166 174
167 175 @property
168 176 def contents_manager(self):
169 177 return self.settings['contents_manager']
170 178
171 179 @property
172 180 def cluster_manager(self):
173 181 return self.settings['cluster_manager']
174 182
175 183 @property
176 184 def session_manager(self):
177 185 return self.settings['session_manager']
178 186
179 187 @property
180 188 def terminal_manager(self):
181 189 return self.settings['terminal_manager']
182 190
183 191 @property
184 192 def kernel_spec_manager(self):
185 193 return self.settings['kernel_spec_manager']
186 194
187 195 @property
188 196 def config_manager(self):
189 197 return self.settings['config_manager']
190 198
191 199 #---------------------------------------------------------------
192 200 # CORS
193 201 #---------------------------------------------------------------
194 202
195 203 @property
196 204 def allow_origin(self):
197 205 """Normal Access-Control-Allow-Origin"""
198 206 return self.settings.get('allow_origin', '')
199 207
200 208 @property
201 209 def allow_origin_pat(self):
202 210 """Regular expression version of allow_origin"""
203 211 return self.settings.get('allow_origin_pat', None)
204 212
205 213 @property
206 214 def allow_credentials(self):
207 215 """Whether to set Access-Control-Allow-Credentials"""
208 216 return self.settings.get('allow_credentials', False)
209 217
210 218 def set_default_headers(self):
211 219 """Add CORS headers, if defined"""
212 220 super(IPythonHandler, self).set_default_headers()
213 221 if self.allow_origin:
214 222 self.set_header("Access-Control-Allow-Origin", self.allow_origin)
215 223 elif self.allow_origin_pat:
216 224 origin = self.get_origin()
217 225 if origin and self.allow_origin_pat.match(origin):
218 226 self.set_header("Access-Control-Allow-Origin", origin)
219 227 if self.allow_credentials:
220 228 self.set_header("Access-Control-Allow-Credentials", 'true')
221 229
222 230 def get_origin(self):
223 231 # Handle WebSocket Origin naming convention differences
224 232 # The difference between version 8 and 13 is that in 8 the
225 233 # client sends a "Sec-Websocket-Origin" header and in 13 it's
226 234 # simply "Origin".
227 235 if "Origin" in self.request.headers:
228 236 origin = self.request.headers.get("Origin")
229 237 else:
230 238 origin = self.request.headers.get("Sec-Websocket-Origin", None)
231 239 return origin
232 240
233 241 #---------------------------------------------------------------
234 242 # template rendering
235 243 #---------------------------------------------------------------
236 244
237 245 def get_template(self, name):
238 246 """Return the jinja template object for a given name"""
239 247 return self.settings['jinja2_env'].get_template(name)
240 248
241 249 def render_template(self, name, **ns):
242 250 ns.update(self.template_namespace)
243 251 template = self.get_template(name)
244 252 return template.render(**ns)
245 253
246 254 @property
247 255 def template_namespace(self):
248 256 return dict(
249 257 base_url=self.base_url,
250 258 default_url=self.default_url,
251 259 ws_url=self.ws_url,
252 260 logged_in=self.logged_in,
253 261 login_available=self.login_available,
254 262 static_url=self.static_url,
255 263 sys_info=sys_info,
256 264 contents_js_source=self.contents_js_source,
257 265 version_hash=self.version_hash,
258 266 **self.jinja_template_vars
259 267 )
260 268
261 269 def get_json_body(self):
262 270 """Return the body of the request as JSON data."""
263 271 if not self.request.body:
264 272 return None
265 273 # Do we need to call body.decode('utf-8') here?
266 274 body = self.request.body.strip().decode(u'utf-8')
267 275 try:
268 276 model = json.loads(body)
269 277 except Exception:
270 278 self.log.debug("Bad JSON: %r", body)
271 279 self.log.error("Couldn't parse JSON", exc_info=True)
272 280 raise web.HTTPError(400, u'Invalid JSON in body of request')
273 281 return model
274 282
275 283 def write_error(self, status_code, **kwargs):
276 284 """render custom error pages"""
277 285 exc_info = kwargs.get('exc_info')
278 286 message = ''
279 287 status_message = responses.get(status_code, 'Unknown HTTP Error')
280 288 if exc_info:
281 289 exception = exc_info[1]
282 290 # get the custom message, if defined
283 291 try:
284 292 message = exception.log_message % exception.args
285 293 except Exception:
286 294 pass
287 295
288 296 # construct the custom reason, if defined
289 297 reason = getattr(exception, 'reason', '')
290 298 if reason:
291 299 status_message = reason
292 300
293 301 # build template namespace
294 302 ns = dict(
295 303 status_code=status_code,
296 304 status_message=status_message,
297 305 message=message,
298 306 exception=exception,
299 307 )
300 308
301 309 self.set_header('Content-Type', 'text/html')
302 310 # render the template
303 311 try:
304 312 html = self.render_template('%s.html' % status_code, **ns)
305 313 except TemplateNotFound:
306 314 self.log.debug("No template for %d", status_code)
307 315 html = self.render_template('error.html', **ns)
308 316
309 317 self.write(html)
310 318
311 319
312 320 class APIHandler(IPythonHandler):
313 321 """Base class for API handlers"""
322
323 @property
324 def content_security_policy(self):
325 csp = '; '.join([
326 super(APIHandler, self).content_security_policy,
327 "default-src 'none'",
328 ])
329 return csp
330
314 331 def finish(self, *args, **kwargs):
315 self.set_header('Content-Security-Policy', "default-src 'none'")
316 332 self.set_header('Content-Type', 'application/json')
317 333 return super(APIHandler, self).finish(*args, **kwargs)
318 334
319 335
320 336 class Template404(IPythonHandler):
321 337 """Render our 404 template"""
322 338 def prepare(self):
323 339 raise web.HTTPError(404)
324 340
325 341
326 342 class AuthenticatedFileHandler(IPythonHandler, web.StaticFileHandler):
327 343 """static files should only be accessible when logged in"""
328 344
329 345 @web.authenticated
330 346 def get(self, path):
331 347 if os.path.splitext(path)[1] == '.ipynb':
332 348 name = path.rsplit('/', 1)[-1]
333 349 self.set_header('Content-Type', 'application/json')
334 350 self.set_header('Content-Disposition','attachment; filename="%s"' % name)
335 351
336 352 return web.StaticFileHandler.get(self, path)
337 353
338 354 def set_headers(self):
339 355 super(AuthenticatedFileHandler, self).set_headers()
340 356 # disable browser caching, rely on 304 replies for savings
341 357 if "v" not in self.request.arguments:
342 358 self.add_header("Cache-Control", "no-cache")
343 359
344 360 def compute_etag(self):
345 361 return None
346 362
347 363 def validate_absolute_path(self, root, absolute_path):
348 364 """Validate and return the absolute path.
349 365
350 366 Requires tornado 3.1
351 367
352 368 Adding to tornado's own handling, forbids the serving of hidden files.
353 369 """
354 370 abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
355 371 abs_root = os.path.abspath(root)
356 372 if is_hidden(abs_path, abs_root):
357 373 self.log.info("Refusing to serve hidden file, via 404 Error")
358 374 raise web.HTTPError(404)
359 375 return abs_path
360 376
361 377
362 378 def json_errors(method):
363 379 """Decorate methods with this to return GitHub style JSON errors.
364 380
365 381 This should be used on any JSON API on any handler method that can raise HTTPErrors.
366 382
367 383 This will grab the latest HTTPError exception using sys.exc_info
368 384 and then:
369 385
370 386 1. Set the HTTP status code based on the HTTPError
371 387 2. Create and return a JSON body with a message field describing
372 388 the error in a human readable form.
373 389 """
374 390 @functools.wraps(method)
375 391 @gen.coroutine
376 392 def wrapper(self, *args, **kwargs):
377 393 try:
378 394 result = yield gen.maybe_future(method(self, *args, **kwargs))
379 395 except web.HTTPError as e:
380 396 self.set_header('Content-Type', 'application/json')
381 397 status = e.status_code
382 398 message = e.log_message
383 399 self.log.warn(message)
384 400 self.set_status(e.status_code)
385 401 reply = dict(message=message, reason=e.reason)
386 402 self.finish(json.dumps(reply))
387 403 except Exception:
388 404 self.set_header('Content-Type', 'application/json')
389 405 self.log.error("Unhandled error in API request", exc_info=True)
390 406 status = 500
391 407 message = "Unknown server error"
392 408 t, value, tb = sys.exc_info()
393 409 self.set_status(status)
394 410 tb_text = ''.join(traceback.format_exception(t, value, tb))
395 411 reply = dict(message=message, reason=None, traceback=tb_text)
396 412 self.finish(json.dumps(reply))
397 413 else:
398 414 # FIXME: can use regular return in generators in py3
399 415 raise gen.Return(result)
400 416 return wrapper
401 417
402 418
403 419
404 420 #-----------------------------------------------------------------------------
405 421 # File handler
406 422 #-----------------------------------------------------------------------------
407 423
408 424 # to minimize subclass changes:
409 425 HTTPError = web.HTTPError
410 426
411 427 class FileFindHandler(web.StaticFileHandler):
412 428 """subclass of StaticFileHandler for serving files from a search path"""
413 429
414 430 # cache search results, don't search for files more than once
415 431 _static_paths = {}
416 432
417 433 def set_headers(self):
418 434 super(FileFindHandler, self).set_headers()
419 435 # disable browser caching, rely on 304 replies for savings
420 436 if "v" not in self.request.arguments or \
421 437 any(self.request.path.startswith(path) for path in self.no_cache_paths):
422 438 self.set_header("Cache-Control", "no-cache")
423 439
424 440 def initialize(self, path, default_filename=None, no_cache_paths=None):
425 441 self.no_cache_paths = no_cache_paths or []
426 442
427 443 if isinstance(path, string_types):
428 444 path = [path]
429 445
430 446 self.root = tuple(
431 447 os.path.abspath(os.path.expanduser(p)) + os.sep for p in path
432 448 )
433 449 self.default_filename = default_filename
434 450
435 451 def compute_etag(self):
436 452 return None
437 453
438 454 @classmethod
439 455 def get_absolute_path(cls, roots, path):
440 456 """locate a file to serve on our static file search path"""
441 457 with cls._lock:
442 458 if path in cls._static_paths:
443 459 return cls._static_paths[path]
444 460 try:
445 461 abspath = os.path.abspath(filefind(path, roots))
446 462 except IOError:
447 463 # IOError means not found
448 464 return ''
449 465
450 466 cls._static_paths[path] = abspath
451 467 return abspath
452 468
453 469 def validate_absolute_path(self, root, absolute_path):
454 470 """check if the file should be served (raises 404, 403, etc.)"""
455 471 if absolute_path == '':
456 472 raise web.HTTPError(404)
457 473
458 474 for root in self.root:
459 475 if (absolute_path + os.sep).startswith(root):
460 476 break
461 477
462 478 return super(FileFindHandler, self).validate_absolute_path(root, absolute_path)
463 479
464 480
465 481 class APIVersionHandler(APIHandler):
466 482
467 483 @json_errors
468 484 def get(self):
469 485 # not authenticated, so give as few info as possible
470 486 self.finish(json.dumps({"version":IPython.__version__}))
471 487
472 488
473 489 class TrailingSlashHandler(web.RequestHandler):
474 490 """Simple redirect handler that strips trailing slashes
475 491
476 492 This should be the first, highest priority handler.
477 493 """
478 494
479 495 def get(self):
480 496 self.redirect(self.request.uri.rstrip('/'))
481 497
482 498 post = put = get
483 499
484 500
485 501 class FilesRedirectHandler(IPythonHandler):
486 502 """Handler for redirecting relative URLs to the /files/ handler"""
487 503
488 504 @staticmethod
489 505 def redirect_to_files(self, path):
490 506 """make redirect logic a reusable static method
491 507
492 508 so it can be called from other handlers.
493 509 """
494 510 cm = self.contents_manager
495 511 if cm.dir_exists(path):
496 512 # it's a *directory*, redirect to /tree
497 513 url = url_path_join(self.base_url, 'tree', path)
498 514 else:
499 515 orig_path = path
500 516 # otherwise, redirect to /files
501 517 parts = path.split('/')
502 518
503 519 if not cm.file_exists(path=path) and 'files' in parts:
504 520 # redirect without files/ iff it would 404
505 521 # this preserves pre-2.0-style 'files/' links
506 522 self.log.warn("Deprecated files/ URL: %s", orig_path)
507 523 parts.remove('files')
508 524 path = '/'.join(parts)
509 525
510 526 if not cm.file_exists(path=path):
511 527 raise web.HTTPError(404)
512 528
513 529 url = url_path_join(self.base_url, 'files', path)
514 530 url = url_escape(url)
515 531 self.log.debug("Redirecting %s to %s", self.request.path, url)
516 532 self.redirect(url)
517 533
518 534 def get(self, path=''):
519 535 return self.redirect_to_files(self, path)
520 536
521 537
522 538 #-----------------------------------------------------------------------------
523 539 # URL pattern fragments for re-use
524 540 #-----------------------------------------------------------------------------
525 541
526 542 # path matches any number of `/foo[/bar...]` or just `/` or ''
527 543 path_regex = r"(?P<path>(?:(?:/[^/]+)+|/?))"
528 544
529 545 #-----------------------------------------------------------------------------
530 546 # URL to handler mappings
531 547 #-----------------------------------------------------------------------------
532 548
533 549
534 550 default_handlers = [
535 551 (r".*/", TrailingSlashHandler),
536 552 (r"api", APIVersionHandler)
537 553 ]
@@ -1,139 +1,141 b''
1 1 """Test the kernels service API."""
2 2
3 3 import json
4 4 import requests
5 5
6 6 from IPython.html.utils import url_path_join
7 7 from IPython.html.tests.launchnotebook import NotebookTestBase, assert_http_error
8 8
9 9 class KernelAPI(object):
10 10 """Wrapper for kernel REST API requests"""
11 11 def __init__(self, base_url):
12 12 self.base_url = base_url
13 13
14 14 def _req(self, verb, path, body=None):
15 15 response = requests.request(verb,
16 16 url_path_join(self.base_url, 'api/kernels', path), data=body)
17 17
18 18 if 400 <= response.status_code < 600:
19 19 try:
20 20 response.reason = response.json()['message']
21 21 except:
22 22 pass
23 23 response.raise_for_status()
24 24
25 25 return response
26 26
27 27 def list(self):
28 28 return self._req('GET', '')
29 29
30 30 def get(self, id):
31 31 return self._req('GET', id)
32 32
33 33 def start(self, name='python'):
34 34 body = json.dumps({'name': name})
35 35 return self._req('POST', '', body)
36 36
37 37 def shutdown(self, id):
38 38 return self._req('DELETE', id)
39 39
40 40 def interrupt(self, id):
41 41 return self._req('POST', url_path_join(id, 'interrupt'))
42 42
43 43 def restart(self, id):
44 44 return self._req('POST', url_path_join(id, 'restart'))
45 45
46 46 class KernelAPITest(NotebookTestBase):
47 47 """Test the kernels web service API"""
48 48 def setUp(self):
49 49 self.kern_api = KernelAPI(self.base_url())
50 50
51 51 def tearDown(self):
52 52 for k in self.kern_api.list().json():
53 53 self.kern_api.shutdown(k['id'])
54 54
55 55 def test__no_kernels(self):
56 56 """Make sure there are no kernels running at the start"""
57 57 kernels = self.kern_api.list().json()
58 58 self.assertEqual(kernels, [])
59 59
60 60 def test_default_kernel(self):
61 61 # POST request
62 62 r = self.kern_api._req('POST', '')
63 63 kern1 = r.json()
64 64 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
65 65 self.assertEqual(r.status_code, 201)
66 66 self.assertIsInstance(kern1, dict)
67 67
68 68 self.assertEqual(r.headers['Content-Security-Policy'], (
69 69 "frame-ancestors 'self'; "
70 "report-uri /api/security/csp-report;"
70 "report-uri /api/security/csp-report; "
71 "default-src 'none'"
71 72 ))
72 73
73 74 def test_main_kernel_handler(self):
74 75 # POST request
75 76 r = self.kern_api.start()
76 77 kern1 = r.json()
77 78 self.assertEqual(r.headers['location'], '/api/kernels/' + kern1['id'])
78 79 self.assertEqual(r.status_code, 201)
79 80 self.assertIsInstance(kern1, dict)
80 81
81 82 self.assertEqual(r.headers['Content-Security-Policy'], (
82 83 "frame-ancestors 'self'; "
83 "report-uri /api/security/csp-report;"
84 "report-uri /api/security/csp-report; "
85 "default-src 'none'"
84 86 ))
85 87
86 88 # GET request
87 89 r = self.kern_api.list()
88 90 self.assertEqual(r.status_code, 200)
89 91 assert isinstance(r.json(), list)
90 92 self.assertEqual(r.json()[0]['id'], kern1['id'])
91 93 self.assertEqual(r.json()[0]['name'], kern1['name'])
92 94
93 95 # create another kernel and check that they both are added to the
94 96 # list of kernels from a GET request
95 97 kern2 = self.kern_api.start().json()
96 98 assert isinstance(kern2, dict)
97 99 r = self.kern_api.list()
98 100 kernels = r.json()
99 101 self.assertEqual(r.status_code, 200)
100 102 assert isinstance(kernels, list)
101 103 self.assertEqual(len(kernels), 2)
102 104
103 105 # Interrupt a kernel
104 106 r = self.kern_api.interrupt(kern2['id'])
105 107 self.assertEqual(r.status_code, 204)
106 108
107 109 # Restart a kernel
108 110 r = self.kern_api.restart(kern2['id'])
109 111 self.assertEqual(r.headers['Location'], '/api/kernels/'+kern2['id'])
110 112 rekern = r.json()
111 113 self.assertEqual(rekern['id'], kern2['id'])
112 114 self.assertEqual(rekern['name'], kern2['name'])
113 115
114 116 def test_kernel_handler(self):
115 117 # GET kernel with given id
116 118 kid = self.kern_api.start().json()['id']
117 119 r = self.kern_api.get(kid)
118 120 kern1 = r.json()
119 121 self.assertEqual(r.status_code, 200)
120 122 assert isinstance(kern1, dict)
121 123 self.assertIn('id', kern1)
122 124 self.assertEqual(kern1['id'], kid)
123 125
124 126 # Request a bad kernel id and check that a JSON
125 127 # message is returned!
126 128 bad_id = '111-111-111-111-111'
127 129 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
128 130 self.kern_api.get(bad_id)
129 131
130 132 # DELETE kernel with id
131 133 r = self.kern_api.shutdown(kid)
132 134 self.assertEqual(r.status_code, 204)
133 135 kernels = self.kern_api.list().json()
134 136 self.assertEqual(kernels, [])
135 137
136 138 # Request to delete a non-existent kernel id
137 139 bad_id = '111-111-111-111-111'
138 140 with assert_http_error(404, 'Kernel does not exist: ' + bad_id):
139 141 self.kern_api.shutdown(bad_id)
General Comments 0
You need to be logged in to leave comments. Login now