Test Failed
Pull Request — master (#1141)
by Antonio
01:57
created

APIServer.get_authenticate_options()   A

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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