Passed
Pull Request — master (#1141)
by Antonio
02:48
created

APIServer.get_authenticate_options()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 4
nop 0
dl 0
loc 5
ccs 3
cts 3
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
"""Module used to handle a API Server."""
2 1
import json
3 1
import logging
4 1
import os
5 1
import shutil
6 1
import sys
7 1
import warnings
8 1
import zipfile
9 1
from datetime import datetime
10 1
from glob import glob
11 1
from http import HTTPStatus
12 1
from urllib.error import HTTPError, URLError
13 1
from urllib.request import urlopen, urlretrieve
14
15 1
from flask import Blueprint, Flask, jsonify, request, send_file
16 1
from flask_cors import CORS
17 1
from flask_socketio import SocketIO, join_room, leave_room
18 1
from werkzeug.exceptions import HTTPException
19
20 1
from kytos.core.auth import authenticated
21 1
from kytos.core.config import KytosConfig
22
23
24 1
class APIServer:
25
    """Api server used to provide Kytos Controller routes."""
26
27
    #: tuple: Default Flask HTTP methods.
28 1
    DEFAULT_METHODS = ('GET',)
29 1
    _NAPP_PREFIX = "/api/{napp.username}/{napp.name}/"
30 1
    _CORE_PREFIX = "/api/kytos/core/"
31
32
    # pylint: disable=too-many-arguments
33 1
    def __init__(self, app_name, listen='0.0.0.0', port=8181,
34
                 napps_manager=None, napps_dir=None):
35
        """Start a Flask+SocketIO server.
36
37
        Require controller to get NApps dir and NAppsManager
38
39
        Args:
40
            app_name(string): String representing a App Name
41
            listen (string): host name used by api server instance
42
            port (int): Port number used by api server instance
43
            controller(kytos.core.controller): A controller instance.
44
        """
45 1
        dirname = os.path.dirname(os.path.abspath(__file__))
46 1
        self.napps_manager = napps_manager
47 1
        self.napps_dir = napps_dir
48
49 1
        self.flask_dir = os.path.join(dirname, '../web-ui')
50 1
        self.log = logging.getLogger(__name__)
51
52 1
        self.listen = listen
53 1
        self.port = port
54
55 1
        self.app = Flask(app_name, root_path=self.flask_dir,
56
                         static_folder="dist", static_url_path="/dist")
57 1
        self.server = SocketIO(self.app, async_mode='threading')
58 1
        self._enable_websocket_rooms()
59
        # ENABLE CROSS ORIGIN RESOURCE SHARING
60 1
        CORS(self.app)
61
62
        # Disable trailing slash
63 1
        self.app.url_map.strict_slashes = False
64
65
        # Update web-ui if necessary
66 1
        self.update_web_ui(force=False)
67
68 1
        @self.app.errorhandler(HTTPException)
69
        def handle_exception(exception):
70
            # pylint: disable=unused-variable, invalid-name
71
            """Return a json for HTTP errors
72
73
            This handler allows NApps to return HTTP error messages
74
            by raising a HTTPException. When raising the Exception,
75
            create it with a description.
76
            """
77
            response = jsonify({
78
                'code': exception.code,
79
                'name': exception.name,
80
                'description': exception.description
81
            })
82
            return response, exception.code
83
84 1
    def _enable_websocket_rooms(self):
85 1
        socket = self.server
86 1
        socket.on_event('join', join_room)
87 1
        socket.on_event('leave', leave_room)
88
89 1
    def run(self):
90
        """Run the Flask API Server."""
91 1
        try:
92 1
            self.server.run(self.app, self.listen, self.port)
93 1
        except OSError as exception:
94 1
            msg = "Couldn't start API Server: {}".format(exception)
95 1
            self.log.critical(msg)
96 1
            sys.exit(msg)
97
98 1
    def register_rest_endpoint(self, url, function, methods):
99
        """Deprecate in favor of @rest decorator."""
100 1
        warnings.warn("From now on, use @rest decorator.", DeprecationWarning,
101
                      stacklevel=2)
102 1
        if url.startswith('/'):
103
            url = url[1:]
104 1
        self._start_endpoint(self.app, f'/kytos/{url}', function,
105
                             methods=methods)
106
107 1
    def start_api(self):
108
        """Start this APIServer instance API.
109
110
        Start /api/kytos/core/shutdown/ and status/ endpoints, web UI.
111
        """
112 1
        self.register_core_endpoint('shutdown/', self.shutdown_api)
113 1
        self.register_core_endpoint('status/', self.status_api)
114 1
        self.register_core_endpoint('web/update/<version>/',
115
                                    self.update_web_ui,
116
                                    methods=['POST'])
117 1
        self.register_core_endpoint('web/update/',
118
                                    self.update_web_ui,
119
                                    methods=['POST'])
120
121 1
        self.register_core_napp_services()
122
123 1
        self._register_web_ui()
124
125 1
    def register_core_endpoint(self, rule, function, **options):
126
        """Register an endpoint with the URL /api/kytos/core/<rule>.
127
128
        Not used by NApps, but controller.
129
        """
130 1
        self._start_endpoint(self.app, self._CORE_PREFIX + rule, function,
131
                             **options)
132
133 1
    def _register_web_ui(self):
134
        """Register routes to the admin-ui homepage."""
135 1
        self.app.add_url_rule('/', self.web_ui.__name__, self.web_ui)
136 1
        self.app.add_url_rule('/index.html', self.web_ui.__name__, self.web_ui)
137 1
        self.app.add_url_rule('/ui/<username>/<napp_name>/<path:filename>',
138
                              self.static_web_ui.__name__, self.static_web_ui)
139 1
        self.app.add_url_rule('/ui/<path:section_name>',
140
                              self.get_ui_components.__name__,
141
                              self.get_ui_components)
142
143 1
    @staticmethod
144
    def status_api():
145
        """Display kytos status using the route ``/kytos/status/``."""
146 1
        return '{"response": "running"}', HTTPStatus.OK.value
147
148 1
    def stop_api_server(self):
149
        """Send a shutdown request to stop Api Server."""
150 1
        try:
151 1
            url = f'http://127.0.0.1:{self.port}/api/kytos/core/shutdown'
152 1
            urlopen(url)
153
        except URLError:
154
            pass
155
156 1
    def shutdown_api(self):
157
        """Handle shutdown requests received by Api Server.
158
159
        This method must be called by kytos using the method
160
        stop_api_server, otherwise this request will be ignored.
161
        """
162 1
        allowed_host = ['127.0.0.1:'+str(self.port),
163
                        'localhost:'+str(self.port)]
164 1
        if request.host not in allowed_host:
165 1
            return "", HTTPStatus.FORBIDDEN.value
166
167 1
        self.server.stop()
168
169 1
        return 'Server shutting down...', HTTPStatus.OK.value
170
171 1
    def static_web_ui(self, username, napp_name, filename):
172
        """Serve static files from installed napps."""
173 1
        path = f"{self.napps_dir}/{username}/{napp_name}/ui/{filename}"
174 1
        if os.path.exists(path):
175 1
            return send_file(path)
176 1
        return "", HTTPStatus.NOT_FOUND.value
177
178 1
    def get_ui_components(self, section_name):
179
        """Return all napps ui components from an specific section.
180
181
        The component name generated will have the following structure:
182
        {username}-{nappname}-{component-section}-{filename}`
183
184
        Args:
185
            section_name (str): Specific section name
186
187
        Returns:
188
            str: Json with a list of all components found.
189
190
        """
191 1
        section_name = '*' if section_name == "all" else section_name
192 1
        path = f"{self.napps_dir}/*/*/ui/{section_name}/*.kytos"
193 1
        components = []
194 1
        for name in glob(path):
195 1
            dirs_name = name.split('/')
196 1
            dirs_name.remove('ui')
197
198 1
            component_name = '-'.join(dirs_name[-4:]).replace('.kytos', '')
199 1
            url = f'ui/{"/".join(dirs_name[-4:])}'
200 1
            component = {'name': component_name, 'url': url}
201 1
            components.append(component)
202 1
        return jsonify(components)
203
204 1
    def web_ui(self):
205
        """Serve the index.html page for the admin-ui."""
206 1
        return send_file(f"{self.flask_dir}/index.html")
207
208 1
    def update_web_ui(self, version='latest', force=True):
209
        """Update the static files for the Web UI.
210
211
        Download the latest files from the UI github repository and update them
212
        in the ui folder.
213
        The repository link is currently hardcoded here.
214
        """
215 1
        if version == 'latest':
216 1
            try:
217 1
                url = 'https://api.github.com/repos/kytos/ui/releases/latest'
218 1
                response = urlopen(url)
219 1
                data = response.readlines()[0]
220 1
                version = json.loads(data)['tag_name']
221 1
            except URLError:
222 1
                version = '1.1.1'
223
224 1
        repository = "https://github.com/kytos/ui"
225 1
        uri = repository + f"/releases/download/{version}/latest.zip"
226
227 1
        if not os.path.exists(self.flask_dir) or force:
228
            # download from github
229 1
            try:
230 1
                package = urlretrieve(uri)[0]
231 1
            except HTTPError:
232 1
                return f"Uri not found {uri}."
233
234
            # test downloaded zip file
235 1
            zip_ref = zipfile.ZipFile(package, 'r')
236
237 1
            if zip_ref.testzip() is not None:
238 1
                return f'Zip file from {uri} is corrupted.'
239
240
            # backup the old web-ui files and create a new web-ui folder
241 1
            if os.path.exists(self.flask_dir):
242 1
                date = datetime.now().strftime("%Y%m%d%H%M%S")
243 1
                shutil.move(self.flask_dir, f"{self.flask_dir}-{date}")
244 1
                os.mkdir(self.flask_dir)
245
246
            # unzip and extract files to web-ui/*
247 1
            zip_ref.extractall(self.flask_dir)
248 1
            zip_ref.close()
249
250 1
        return "updated the web ui"
251
252
    # BEGIN decorator methods
253
254 1
    @staticmethod
255
    def decorate_as_endpoint(rule, **options):
256
        """Decorate methods as REST endpoints.
257
258
        Example for URL ``/api/myusername/mynapp/sayhello/World``:
259
260
        .. code-block:: python3
261
262
           from flask.json import jsonify
263
           from kytos.core.napps import rest
264
265
           @rest('sayhello/<string:name>')
266
           def say_hello(name):
267
               return jsonify({"data": f"Hello, {name}!"})
268
269
        ``@rest`` parameters are the same as Flask's ``@app.route``. You can
270
        also add ``methods=['POST']``, for example.
271
272
        As we don't have the NApp instance now, we store the parameters in a
273
        method attribute in order to add the route later, after we have both
274
        APIServer and NApp instances.
275
        """
276 1
        def store_route_params(function):
277
            """Store ``Flask`` ``@route`` parameters in a method attribute.
278
279
            There can be many @route decorators in a single function.
280
            """
281
            # To support any order: @classmethod, @rest or @rest, @classmethod
282
            # class and static decorators return a descriptor with the function
283
            # in __func__.
284 1
            if isinstance(function, (classmethod, staticmethod)):
285 1
                inner = function.__func__
286
            else:
287 1
                inner = function
288
            # Add route parameters
289 1
            if not hasattr(inner, 'route_params'):
290 1
                inner.route_params = []
291 1
            inner.route_params.append((rule, options))
292
            # Return the same function, now with "route_params" attribute
293 1
            return function
294 1
        return store_route_params
295
296 1
    @staticmethod
297
    def get_authenticate_options():
298
        """Return configuration options related to authentication."""
299 1
        options = KytosConfig().options['daemon']
300 1
        return options.authenticate_urls
301
302 1
    def authenticate_endpoints(self, napp):
303
        """Add authentication to defined REST endpoints.
304
305
        If any URL marked for authentication uses a function,
306
        that function will require authentication.
307
        """
308 1
        authenticate_urls = self.get_authenticate_options()
309 1
        for function in self._get_decorated_functions(napp):
310 1
            if isinstance(function, (classmethod, staticmethod)):
311
                inner = function.__func__
312
            else:
313 1
                inner = function
314 1
            inner.authenticated = False
315 1
            for rule, _ in function.route_params:
316
                if inner.authenticated:
317
                    break
318
                absolute_rule = self.get_absolute_rule(rule, napp)
319
                for url in authenticate_urls:
320
                    if url in absolute_rule:
321
                        inner.authenticated = True
322
                        break
323
324 1
    def register_napp_endpoints(self, napp):
325
        """Add all NApp REST endpoints with @rest decorator.
326
327
        We are using Flask Blueprints to register these endpoints. Blueprints
328
        are essentially the Flask equivalent of Python modules and are used to
329
        keep related logic and assets grouped and separated from one another.
330
331
        URLs will be prefixed with ``/api/{username}/{napp_name}/``.
332
333
        Args:
334
            napp (Napp): Napp instance to register new endpoints.
335
        """
336
        # Create a Flask Blueprint for a specific NApp
337 1
        napp_blueprint = Blueprint(napp.napp_id, __name__)
338
339
        # Start all endpoints for this NApp
340 1
        for function in self._get_decorated_functions(napp):
341 1
            for rule, options in function.route_params:
342 1
                absolute_rule = self.get_absolute_rule(rule, napp)
343 1
                if getattr(function, 'authenticated', False):
344
                    function = authenticated(function)
345 1
                self._start_endpoint(napp_blueprint, absolute_rule,
346
                                     function, **options)
347
348
        # Register this Flask Blueprint in the Flask App
349 1
        self.app.register_blueprint(napp_blueprint)
350
351 1
    @staticmethod
352
    def _get_decorated_functions(napp):
353
        """Return ``napp``'s methods having the @rest decorator."""
354 1
        for name in dir(napp):
355 1
            if not name.startswith('_'):  # discarding private names
356 1
                pub_attr = getattr(napp, name)
357 1
                if callable(pub_attr) and hasattr(pub_attr, 'route_params'):
358 1
                    yield pub_attr
359
360 1
    @classmethod
361
    def get_absolute_rule(cls, rule, napp):
362
        """Prefix the rule, e.g. "flow" to "/api/user/napp/flow".
363
364
        This code is used by kytos-utils when generating an OpenAPI skel.
365
        """
366
        # Flask does require 2 slashes if specified, so we remove a starting
367
        # slash if applicable.
368 1
        relative_rule = rule[1:] if rule.startswith('/') else rule
369 1
        return cls._NAPP_PREFIX.format(napp=napp) + relative_rule
370
371
    # END decorator methods
372
373 1
    def _start_endpoint(self, app, rule, function, **options):
374
        """Start ``function``'s endpoint.
375
376
        Forward parameters to ``Flask.add_url_rule`` mimicking Flask
377
        ``@route`` decorator.
378
        """
379 1
        endpoint = options.pop('endpoint', None)
380 1
        app.add_url_rule(rule, endpoint, function, **options)
381 1
        self.log.info('Started %s - %s', rule,
382
                      ', '.join(options.get('methods', self.DEFAULT_METHODS)))
383
384 1
    def remove_napp_endpoints(self, napp):
385
        """Remove all decorated endpoints.
386
387
        Args:
388
            napp (Napp): Napp instance to look for rest-decorated methods.
389
        """
390 1
        prefix = self._NAPP_PREFIX.format(napp=napp)
391 1
        indexes = []
392 1
        endpoints = set()
393 1
        for index, rule in enumerate(self.app.url_map.iter_rules()):
394 1
            if rule.rule.startswith(prefix):
395 1
                endpoints.add(rule.endpoint)
396 1
                indexes.append(index)
397 1
                self.log.info('Stopped %s - %s', rule, ','.join(rule.methods))
398
399 1
        for endpoint in endpoints:
400 1
            self.app.view_functions.pop(endpoint)
401
402 1
        for index in reversed(indexes):
403
            # pylint: disable=protected-access
404 1
            self.app.url_map._rules.pop(index)
405
            # pylint: enable=protected-access
406
407
        # Remove the Flask Blueprint of this NApp from the Flask App
408 1
        self.app.blueprints.pop(napp.napp_id)
409
410 1
        self.log.info('The Rest endpoints from %s were disabled.', prefix)
411
412 1
    def register_core_napp_services(self):
413
        """
414
        Register /kytos/core/ services over NApps.
415
416
        It registers enable, disable, install, uninstall NApps that will
417
        be used by kytos-utils.
418
        """
419 1
        self.register_core_endpoint("napps/<username>/<napp_name>/enable",
420
                                    self._enable_napp)
421 1
        self.register_core_endpoint("napps/<username>/<napp_name>/disable",
422
                                    self._disable_napp)
423 1
        self.register_core_endpoint("napps/<username>/<napp_name>/install",
424
                                    self._install_napp)
425 1
        self.register_core_endpoint("napps/<username>/<napp_name>/uninstall",
426
                                    self._uninstall_napp)
427 1
        self.register_core_endpoint("napps_enabled",
428
                                    self._list_enabled_napps)
429 1
        self.register_core_endpoint("napps_installed",
430
                                    self._list_installed_napps)
431 1
        self.register_core_endpoint(
432
            "napps/<username>/<napp_name>/metadata/<key>",
433
            self._get_napp_metadata)
434
435 1
    def _enable_napp(self, username, napp_name):
436
        """
437
        Enable an installed NApp.
438
439
        :param username: NApps user name
440
        :param napp_name: NApp name
441
        :return: JSON content and return code
442
        """
443
        # Check if the NApp is installed
444 1
        if not self.napps_manager.is_installed(username, napp_name):
445 1
            return '{"response": "not installed"}', \
446
                   HTTPStatus.BAD_REQUEST.value
447
448
        # Check if the NApp is already been enabled
449 1
        if not self.napps_manager.is_enabled(username, napp_name):
450 1
            self.napps_manager.enable(username, napp_name)
451
452
        # Check if NApp is enabled
453 1
        if not self.napps_manager.is_enabled(username, napp_name):
454
            # If it is not enabled an admin user must check the log file
455 1
            return '{"response": "error"}', \
456
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
457
458 1
        return '{"response": "enabled"}', HTTPStatus.OK.value
459
460 1
    def _disable_napp(self, username, napp_name):
461
        """
462
        Disable an installed NApp.
463
464
        :param username: NApps user name
465
        :param napp_name: NApp name
466
        :return: JSON content and return code
467
        """
468
        # Check if the NApp is installed
469 1
        if not self.napps_manager.is_installed(username, napp_name):
470 1
            return '{"response": "not installed"}', \
471
                   HTTPStatus.BAD_REQUEST.value
472
473
        # Check if the NApp is enabled
474 1
        if self.napps_manager.is_enabled(username, napp_name):
475 1
            self.napps_manager.disable(username, napp_name)
476
477
        # Check if NApp is still enabled
478 1
        if self.napps_manager.is_enabled(username, napp_name):
479
            # If it is still enabled an admin user must check the log file
480 1
            return '{"response": "error"}', \
481
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
482
483 1
        return '{"response": "disabled"}', \
484
               HTTPStatus.OK.value
485
486 1
    def _install_napp(self, username, napp_name):
487
        # Check if the NApp is installed
488 1
        if self.napps_manager.is_installed(username, napp_name):
489 1
            return '{"response": "installed"}', HTTPStatus.OK.value
490
491 1
        napp = "{}/{}".format(username, napp_name)
492
493
        # Try to install and enable the napp
494 1
        try:
495 1
            if not self.napps_manager.install(napp, enable=True):
496
                # If it is not installed an admin user must check the log file
497 1
                return '{"response": "error"}', \
498
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
499 1
        except HTTPError as exception:
500 1
            return '{"response": "error"}', exception.code
501
502 1
        return '{"response": "installed"}', HTTPStatus.OK.value
503
504 1
    def _uninstall_napp(self, username, napp_name):
505
        # Check if the NApp is installed
506 1
        if self.napps_manager.is_installed(username, napp_name):
507
            # Try to unload/uninstall the napp
508 1
            if not self.napps_manager.uninstall(username, napp_name):
509
                # If it is not uninstalled admin user must check the log file
510 1
                return '{"response": "error"}', \
511
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
512
513 1
        return '{"response": "uninstalled"}', HTTPStatus.OK.value
514
515 1
    def _list_enabled_napps(self):
516
        """Sorted list of (username, napp_name) of enabled napps."""
517 1
        serialized_dict = json.dumps(
518
                            self.napps_manager.get_enabled_napps(),
519
                            default=lambda a: [a.username, a.name])
520
521 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
522
523 1
    def _list_installed_napps(self):
524
        """Sorted list of (username, napp_name) of installed napps."""
525 1
        serialized_dict = json.dumps(
526
                            self.napps_manager.get_installed_napps(),
527
                            default=lambda a: [a.username, a.name])
528
529 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
530
531 1
    def _get_napp_metadata(self, username, napp_name, key):
532
        """Get NApp metadata value.
533
534
        For safety reasons, only some keys can be retrieved:
535
        napp_dependencies, description, version.
536
537
        """
538 1
        valid_keys = ['napp_dependencies', 'description', 'version']
539
540 1
        if not self.napps_manager.is_installed(username, napp_name):
541 1
            return "NApp is not installed.", HTTPStatus.BAD_REQUEST.value
542
543 1
        if key not in valid_keys:
544 1
            return "Invalid key.", HTTPStatus.BAD_REQUEST.value
545
546 1
        data = self.napps_manager.get_napp_metadata(username, napp_name, key)
547 1
        serialized_dict = json.dumps({key: data})
548
549
        return '%s' % serialized_dict, HTTPStatus.OK.value
550