Passed
Pull Request — master (#1141)
by Antonio
03:17
created

APIServer.authenticate_endpoints()   B

Complexity

Conditions 7

Size

Total Lines 21
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 14.432

Importance

Changes 0
Metric Value
cc 7
eloc 15
nop 2
dl 0
loc 21
ccs 7
cts 15
cp 0.4667
crap 14.432
rs 8
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
        index_path = f"{self.flask_dir}/index.html"
207 1
        if os.path.exists(index_path):
208 1
            return send_file(index_path)
209 1
        return f"File '{index_path}' not found.", HTTPStatus.NOT_FOUND.value
210
211 1
    def update_web_ui(self, version='latest', force=True):
212
        """Update the static files for the Web UI.
213
214
        Download the latest files from the UI github repository and update them
215
        in the ui folder.
216
        The repository link is currently hardcoded here.
217
        """
218 1
        if version == 'latest':
219 1
            try:
220 1
                url = 'https://api.github.com/repos/kytos/ui/releases/latest'
221 1
                response = urlopen(url)
222 1
                data = response.readlines()[0]
223 1
                version = json.loads(data)['tag_name']
224 1
            except URLError:
225 1
                version = '1.1.1'
226
227 1
        repository = "https://github.com/kytos/ui"
228 1
        uri = repository + f"/releases/download/{version}/latest.zip"
229
230 1
        if not os.path.exists(self.flask_dir) or force:
231
            # download from github
232 1
            try:
233 1
                package = urlretrieve(uri)[0]
234 1
            except HTTPError:
235 1
                return f"Uri not found {uri}."
236
            except URLError:
237
                self.log.warning("Error accessing URL %s.", uri)
238
                return f"Error accessing URL {uri}."
239
240
            # test downloaded zip file
241 1
            zip_ref = zipfile.ZipFile(package, 'r')
242
243 1
            if zip_ref.testzip() is not None:
244 1
                return f'Zip file from {uri} is corrupted.'
245
246
            # backup the old web-ui files and create a new web-ui folder
247 1
            if os.path.exists(self.flask_dir):
248 1
                date = datetime.now().strftime("%Y%m%d%H%M%S")
249 1
                shutil.move(self.flask_dir, f"{self.flask_dir}-{date}")
250 1
                os.mkdir(self.flask_dir)
251
252
            # unzip and extract files to web-ui/*
253 1
            zip_ref.extractall(self.flask_dir)
254 1
            zip_ref.close()
255
256 1
        return "updated the web ui"
257
258
    # BEGIN decorator methods
259
260 1
    @staticmethod
261
    def decorate_as_endpoint(rule, **options):
262
        """Decorate methods as REST endpoints.
263
264
        Example for URL ``/api/myusername/mynapp/sayhello/World``:
265
266
        .. code-block:: python3
267
268
           from flask.json import jsonify
269
           from kytos.core.napps import rest
270
271
           @rest('sayhello/<string:name>')
272
           def say_hello(name):
273
               return jsonify({"data": f"Hello, {name}!"})
274
275
        ``@rest`` parameters are the same as Flask's ``@app.route``. You can
276
        also add ``methods=['POST']``, for example.
277
278
        As we don't have the NApp instance now, we store the parameters in a
279
        method attribute in order to add the route later, after we have both
280
        APIServer and NApp instances.
281
        """
282 1
        def store_route_params(function):
283
            """Store ``Flask`` ``@route`` parameters in a method attribute.
284
285
            There can be many @route decorators in a single function.
286
            """
287
            # To support any order: @classmethod, @rest or @rest, @classmethod
288
            # class and static decorators return a descriptor with the function
289
            # in __func__.
290 1
            if isinstance(function, (classmethod, staticmethod)):
291 1
                inner = function.__func__
292
            else:
293 1
                inner = function
294
            # Add route parameters
295 1
            if not hasattr(inner, 'route_params'):
296 1
                inner.route_params = []
297 1
            inner.route_params.append((rule, options))
298
            # Return the same function, now with "route_params" attribute
299 1
            return function
300 1
        return store_route_params
301
302 1
    @staticmethod
303
    def get_authenticate_options():
304
        """Return configuration options related to authentication."""
305 1
        options = KytosConfig().options['daemon']
306 1
        return options.authenticate_urls
307
308 1
    def authenticate_endpoints(self, napp):
309
        """Add authentication to defined REST endpoints.
310
311
        If any URL marked for authentication uses a function,
312
        that function will require authentication.
313
        """
314 1
        authenticate_urls = self.get_authenticate_options()
315 1
        for function in self._get_decorated_functions(napp):
316 1
            if hasattr(function, '__func__'):
317
                inner = function.__func__
318
            else:
319 1
                inner = function
320 1
            inner.authenticated = False
321 1
            for rule, _ in function.route_params:
322
                if inner.authenticated:
323
                    break
324
                absolute_rule = self.get_absolute_rule(rule, napp)
325
                for url in authenticate_urls:
326
                    if url in absolute_rule:
327
                        inner.authenticated = True
328
                        break
329
330 1
    def register_napp_endpoints(self, napp):
331
        """Add all NApp REST endpoints with @rest decorator.
332
333
        We are using Flask Blueprints to register these endpoints. Blueprints
334
        are essentially the Flask equivalent of Python modules and are used to
335
        keep related logic and assets grouped and separated from one another.
336
337
        URLs will be prefixed with ``/api/{username}/{napp_name}/``.
338
339
        Args:
340
            napp (Napp): Napp instance to register new endpoints.
341
        """
342
        # Create a Flask Blueprint for a specific NApp
343 1
        napp_blueprint = Blueprint(napp.napp_id, __name__)
344
345
        # Start all endpoints for this NApp
346 1
        for function in self._get_decorated_functions(napp):
347 1
            for rule, options in function.route_params:
348 1
                absolute_rule = self.get_absolute_rule(rule, napp)
349 1
                if getattr(function, 'authenticated', False):
350
                    function = authenticated(function)
351 1
                self._start_endpoint(napp_blueprint, absolute_rule,
352
                                     function, **options)
353
354
        # Register this Flask Blueprint in the Flask App
355 1
        self.app.register_blueprint(napp_blueprint)
356
357 1
    @staticmethod
358
    def _get_decorated_functions(napp):
359
        """Return ``napp``'s methods having the @rest decorator."""
360 1
        for name in dir(napp):
361 1
            if not name.startswith('_'):  # discarding private names
362 1
                pub_attr = getattr(napp, name)
363 1
                if callable(pub_attr) and hasattr(pub_attr, 'route_params'):
364 1
                    yield pub_attr
365
366 1
    @classmethod
367
    def get_absolute_rule(cls, rule, napp):
368
        """Prefix the rule, e.g. "flow" to "/api/user/napp/flow".
369
370
        This code is used by kytos-utils when generating an OpenAPI skel.
371
        """
372
        # Flask does require 2 slashes if specified, so we remove a starting
373
        # slash if applicable.
374 1
        relative_rule = rule[1:] if rule.startswith('/') else rule
375 1
        return cls._NAPP_PREFIX.format(napp=napp) + relative_rule
376
377
    # END decorator methods
378
379 1
    def _start_endpoint(self, app, rule, function, **options):
380
        """Start ``function``'s endpoint.
381
382
        Forward parameters to ``Flask.add_url_rule`` mimicking Flask
383
        ``@route`` decorator.
384
        """
385 1
        endpoint = options.pop('endpoint', None)
386 1
        app.add_url_rule(rule, endpoint, function, **options)
387 1
        self.log.info('Started %s - %s', rule,
388
                      ', '.join(options.get('methods', self.DEFAULT_METHODS)))
389
390 1
    def remove_napp_endpoints(self, napp):
391
        """Remove all decorated endpoints.
392
393
        Args:
394
            napp (Napp): Napp instance to look for rest-decorated methods.
395
        """
396 1
        prefix = self._NAPP_PREFIX.format(napp=napp)
397 1
        indexes = []
398 1
        endpoints = set()
399 1
        for index, rule in enumerate(self.app.url_map.iter_rules()):
400 1
            if rule.rule.startswith(prefix):
401 1
                endpoints.add(rule.endpoint)
402 1
                indexes.append(index)
403 1
                self.log.info('Stopped %s - %s', rule, ','.join(rule.methods))
404
405 1
        for endpoint in endpoints:
406 1
            self.app.view_functions.pop(endpoint)
407
408 1
        for index in reversed(indexes):
409
            # pylint: disable=protected-access
410 1
            self.app.url_map._rules.pop(index)
411
            # pylint: enable=protected-access
412
413
        # Remove the Flask Blueprint of this NApp from the Flask App
414 1
        self.app.blueprints.pop(napp.napp_id)
415
416 1
        self.log.info('The Rest endpoints from %s were disabled.', prefix)
417
418 1
    def register_core_napp_services(self):
419
        """
420
        Register /kytos/core/ services over NApps.
421
422
        It registers enable, disable, install, uninstall NApps that will
423
        be used by kytos-utils.
424
        """
425 1
        self.register_core_endpoint("napps/<username>/<napp_name>/enable",
426
                                    self._enable_napp)
427 1
        self.register_core_endpoint("napps/<username>/<napp_name>/disable",
428
                                    self._disable_napp)
429 1
        self.register_core_endpoint("napps/<username>/<napp_name>/install",
430
                                    self._install_napp)
431 1
        self.register_core_endpoint("napps/<username>/<napp_name>/uninstall",
432
                                    self._uninstall_napp)
433 1
        self.register_core_endpoint("napps_enabled",
434
                                    self._list_enabled_napps)
435 1
        self.register_core_endpoint("napps_installed",
436
                                    self._list_installed_napps)
437 1
        self.register_core_endpoint(
438
            "napps/<username>/<napp_name>/metadata/<key>",
439
            self._get_napp_metadata)
440
441 1
    def _enable_napp(self, username, napp_name):
442
        """
443
        Enable an installed NApp.
444
445
        :param username: NApps user name
446
        :param napp_name: NApp name
447
        :return: JSON content and return code
448
        """
449
        # Check if the NApp is installed
450 1
        if not self.napps_manager.is_installed(username, napp_name):
451 1
            return '{"response": "not installed"}', \
452
                   HTTPStatus.BAD_REQUEST.value
453
454
        # Check if the NApp is already been enabled
455 1
        if not self.napps_manager.is_enabled(username, napp_name):
456 1
            self.napps_manager.enable(username, napp_name)
457
458
        # Check if NApp is enabled
459 1
        if not self.napps_manager.is_enabled(username, napp_name):
460
            # If it is not enabled an admin user must check the log file
461 1
            return '{"response": "error"}', \
462
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
463
464 1
        return '{"response": "enabled"}', HTTPStatus.OK.value
465
466 1
    def _disable_napp(self, username, napp_name):
467
        """
468
        Disable an installed NApp.
469
470
        :param username: NApps user name
471
        :param napp_name: NApp name
472
        :return: JSON content and return code
473
        """
474
        # Check if the NApp is installed
475 1
        if not self.napps_manager.is_installed(username, napp_name):
476 1
            return '{"response": "not installed"}', \
477
                   HTTPStatus.BAD_REQUEST.value
478
479
        # Check if the NApp is enabled
480 1
        if self.napps_manager.is_enabled(username, napp_name):
481 1
            self.napps_manager.disable(username, napp_name)
482
483
        # Check if NApp is still enabled
484 1
        if self.napps_manager.is_enabled(username, napp_name):
485
            # If it is still enabled an admin user must check the log file
486 1
            return '{"response": "error"}', \
487
                   HTTPStatus.INTERNAL_SERVER_ERROR.value
488
489 1
        return '{"response": "disabled"}', \
490
               HTTPStatus.OK.value
491
492 1
    def _install_napp(self, username, napp_name):
493
        # Check if the NApp is installed
494 1
        if self.napps_manager.is_installed(username, napp_name):
495 1
            return '{"response": "installed"}', HTTPStatus.OK.value
496
497 1
        napp = "{}/{}".format(username, napp_name)
498
499
        # Try to install and enable the napp
500 1
        try:
501 1
            if not self.napps_manager.install(napp, enable=True):
502
                # If it is not installed an admin user must check the log file
503 1
                return '{"response": "error"}', \
504
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
505 1
        except HTTPError as exception:
506 1
            return '{"response": "error"}', exception.code
507
508 1
        return '{"response": "installed"}', HTTPStatus.OK.value
509
510 1
    def _uninstall_napp(self, username, napp_name):
511
        # Check if the NApp is installed
512 1
        if self.napps_manager.is_installed(username, napp_name):
513
            # Try to unload/uninstall the napp
514 1
            if not self.napps_manager.uninstall(username, napp_name):
515
                # If it is not uninstalled admin user must check the log file
516 1
                return '{"response": "error"}', \
517
                       HTTPStatus.INTERNAL_SERVER_ERROR.value
518
519 1
        return '{"response": "uninstalled"}', HTTPStatus.OK.value
520
521 1
    def _list_enabled_napps(self):
522
        """Sorted list of (username, napp_name) of enabled napps."""
523 1
        serialized_dict = json.dumps(
524
                            self.napps_manager.get_enabled_napps(),
525
                            default=lambda a: [a.username, a.name])
526
527 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
528
529 1
    def _list_installed_napps(self):
530
        """Sorted list of (username, napp_name) of installed napps."""
531 1
        serialized_dict = json.dumps(
532
                            self.napps_manager.get_installed_napps(),
533
                            default=lambda a: [a.username, a.name])
534
535 1
        return '{"napps": %s}' % serialized_dict, HTTPStatus.OK.value
536
537 1
    def _get_napp_metadata(self, username, napp_name, key):
538
        """Get NApp metadata value.
539
540
        For safety reasons, only some keys can be retrieved:
541
        napp_dependencies, description, version.
542
543
        """
544 1
        valid_keys = ['napp_dependencies', 'description', 'version']
545
546 1
        if not self.napps_manager.is_installed(username, napp_name):
547 1
            return "NApp is not installed.", HTTPStatus.BAD_REQUEST.value
548
549 1
        if key not in valid_keys:
550 1
            return "Invalid key.", HTTPStatus.BAD_REQUEST.value
551
552 1
        data = self.napps_manager.get_napp_metadata(username, napp_name, key)
553 1
        serialized_dict = json.dumps({key: data})
554
555
        return '%s' % serialized_dict, HTTPStatus.OK.value
556