http_handlers.py 18 KB
Newer Older
Pavel Spirek's avatar
Pavel Spirek committed
1 2 3
import json
import os
import mimetypes
4

Pavel Spirek's avatar
Pavel Spirek committed
5
from collections import OrderedDict
6
from enum import Enum
7
from colorlog import error, warning as warn, info
Pavel Spirek's avatar
Pavel Spirek committed
8
from urllib.parse import parse_qs
9
from typing import Dict, List, Optional
Pavel Spirek's avatar
Pavel Spirek committed
10

11 12
from yangson.exceptions import YangsonException, NonexistentSchemaNode
from yangson.schemanode import ContainerNode, ListNode, GroupNode, LeafListNode, LeafNode
13
from yangson.instance import NonexistentInstance, InstanceValueError, RootNode
Pavel Spirek's avatar
Pavel Spirek committed
14
from yangson.datatype import YangTypeError
Pavel Spirek's avatar
Pavel Spirek committed
15

16
from jetconf.knot_api import KnotError
Pavel Spirek's avatar
Pavel Spirek committed
17
from .config import CONFIG_GLOBAL, CONFIG_HTTP, NACM_ADMINS, API_ROOT_data, API_ROOT_STAGING_data, API_ROOT_ops
18
from .helpers import CertHelpers, DataHelpers, DateTimeHelpers, ErrorHelpers, LogHelpers, SSLCertT
19 20 21 22 23 24 25 26
from .data import (
    BaseDatastore,
    RpcInfo,
    DataLockError,
    NacmForbiddenError,
    NoHandlerError,
    NoHandlerForOpError,
    InstanceAlreadyPresent,
27 28
    ChangeType,
    ConfHandlerFailedError)
Pavel Spirek's avatar
Pavel Spirek committed
29

30
QueryStrT = Dict[str, List[str]]
Pavel Spirek's avatar
Pavel Spirek committed
31
epretty = ErrorHelpers.epretty
32
debug_httph = LogHelpers.create_module_dbg_logger(__name__)
33

Pavel Spirek's avatar
Pavel Spirek committed
34

35 36 37 38
CT_PLAIN = "text/plain"
CT_YANG_JSON = "application/yang.api+json"


39 40 41 42
class HttpRequestError(Exception):
    pass


43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
class HttpStatus(Enum):
    Ok          = ("200", "OK")
    Created     = ("201", "Created")
    NoContent   = ("204", "No Content")
    BadRequest  = ("400", "Bad Request")
    Forbidden   = ("403", "Forbidden")
    NotFound    = ("404", "Not Found")
    MethodNotAllowed    = ("405", "Method Not Allowed")
    NotAcceptable       = ("406", "Not Acceptable")
    Conflict    = ("409", "Conflict")
    InternalServerError = ("500", "Internal Server Error")

    @property
    def code(self) -> str:
        return self.value[0]

    @property
    def msg(self) -> str:
        return self.value[1]


class HttpResponse:
    def __init__(self, status: HttpStatus, data: bytes, content_type: str, extra_headers: OrderedDict=None):
        self.status_code = status.code
        self.data = data
68
        self.content_length = len(data)
69 70 71 72 73 74 75 76 77 78 79 80
        self.content_type = content_type
        self.extra_headers = extra_headers

    @classmethod
    def empty(cls, status: HttpStatus, status_in_body: bool=True) -> "HttpResponse":
        if status_in_body:
            response = status.code + " " + status.msg + "\n"
        else:
            response = ""

        return cls(status, response.encode(), CT_PLAIN)

81

82 83
def unknown_req_handler(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
    return HttpResponse.empty(HttpStatus.BadRequest)
84

85 86

def api_root_handler(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT):
Pavel Spirek's avatar
Pavel Spirek committed
87
    # Top level api resource (appendix D.1.1)
88
    response = (
Pavel Spirek's avatar
Pavel Spirek committed
89 90 91 92 93 94 95
        "{\n"
        "    \"ietf-restconf:restconf\": {\n"
        "        \"data\" : [ null ],\n"
        "        \"operations\" : [ null ]\n"
        "    }\n"
        "}"
    )
96

97
    return HttpResponse(HttpStatus.Ok, response.encode(), CT_YANG_JSON)
Pavel Spirek's avatar
Pavel Spirek committed
98 99


100
def _get(ds: BaseDatastore, pth: str, username: str, yl_data: bool=False, staging: bool=False) -> HttpResponse:
101 102 103 104 105 106 107
    url_split = pth.split("?")
    url_path = url_split[0]
    if len(url_split) > 1:
        query_string = parse_qs(url_split[1])
    else:
        query_string = {}

108
    rpc1 = RpcInfo()
Pavel Spirek's avatar
Pavel Spirek committed
109
    rpc1.username = username
110
    rpc1.path = url_path.rstrip("/")
111
    rpc1.qs = query_string
Pavel Spirek's avatar
Pavel Spirek committed
112 113 114

    try:
        ds.lock_data(username)
115

116 117
        n = None
        http_resp = None
118
        try:
119 120 121 122 123 124 125 126 127 128
            n = ds.get_node_rpc(rpc1, yl_data, staging)
        except NacmForbiddenError as e:
            warn(epretty(e))
            http_resp = HttpResponse.empty(HttpStatus.Forbidden)
        except (NonexistentSchemaNode, NonexistentInstance) as e:
            warn(epretty(e))
            http_resp = HttpResponse.empty(HttpStatus.NotFound)
        except InstanceValueError as e:
            warn(epretty(e))
            http_resp = HttpResponse.empty(HttpStatus.BadRequest)
129
        except (ConfHandlerFailedError, NoHandlerError, KnotError, YangsonException) as e:
130 131 132 133 134 135
            error(epretty(e))
            http_resp = HttpResponse.empty(HttpStatus.InternalServerError)
        finally:
            ds.unlock_data()

        if n is not None:
136 137
            n_value = n.raw_value()

138 139 140 141 142 143 144
            if isinstance(n, RootNode):
                # Getting top-level node
                restconf_env = "ietf-restconf:data"
                restconf_n_value = {restconf_env: n_value}
            else:
                sn = n.schema_node
                if isinstance(sn, (ContainerNode, GroupNode, LeafNode)):
145 146
                    restconf_env = "{}:{}".format(sn.qual_name[1], sn.qual_name[0])
                    restconf_n_value = {restconf_env: n_value}
147
                elif isinstance(sn, ListNode):
148 149 150 151 152 153 154 155 156 157
                    restconf_env = "{}:{}".format(sn.qual_name[1], sn.qual_name[0])
                    if isinstance(n_value, list):
                        # List and list item points to the same schema node
                        restconf_n_value = {restconf_env: n_value}
                    else:
                        restconf_n_value = {restconf_env: [n_value]}
                else:
                    raise HttpRequestError()

            response = json.dumps(restconf_n_value, indent=4)
158 159

            add_headers = OrderedDict()
160
            add_headers["ETag"] = str(hash(n.value))
161 162 163 164 165 166 167 168
            try:
                lm_time = DateTimeHelpers.to_httpdate_str(n.value.timestamp, CONFIG_GLOBAL["TIMEZONE"])
                add_headers["Last-Modified"] = lm_time
            except AttributeError:
                # Only arrays and objects have last_modified attribute
                pass

            http_resp = HttpResponse(HttpStatus.Ok, response.encode(), CT_YANG_JSON, extra_headers=add_headers)
169

Pavel Spirek's avatar
Pavel Spirek committed
170
    except DataLockError as e:
Pavel Spirek's avatar
Pavel Spirek committed
171
        warn(epretty(e))
172
        http_resp = HttpResponse.empty(HttpStatus.InternalServerError)
173 174 175
    except HttpRequestError as e:
        warn(epretty(e))
        http_resp = HttpResponse.empty(HttpStatus.BadRequest)
176 177

    return http_resp
Pavel Spirek's avatar
Pavel Spirek committed
178 179


180
def create_get_api(ds: BaseDatastore):
181 182
    def get_api_closure(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
        username = CertHelpers.get_field(client_cert, "emailAddress")
Pavel Spirek's avatar
Pavel Spirek committed
183
        info("[{}] api_get: {}".format(username, headers[":path"]))
Pavel Spirek's avatar
Pavel Spirek committed
184

185
        api_pth = headers[":path"][len(API_ROOT_data):]
186 187 188 189 190
        ns = DataHelpers.path_first_ns(api_pth)

        if ns == "ietf-netconf-acm":
            if username not in NACM_ADMINS:
                warn(username + " not allowed to access NACM data")
191
                http_resp = HttpResponse.empty(HttpStatus.Forbidden)
192
            else:
193
                http_resp = _get(ds.nacm.nacm_ds, api_pth, username)
194
        elif ns == "ietf-yang-library":
195
            http_resp = _get(ds, api_pth, username, yl_data=True)
196
        else:
197 198 199
            http_resp = _get(ds, api_pth, username)

        return http_resp
Pavel Spirek's avatar
Pavel Spirek committed
200

201
    return get_api_closure
Pavel Spirek's avatar
Pavel Spirek committed
202 203


204
def create_get_staging_api(ds: BaseDatastore):
205 206
    def get_staging_api_closure(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
        username = CertHelpers.get_field(client_cert, "emailAddress")
207 208 209 210 211 212 213 214
        info("[{}] api_get_staging: {}".format(username, headers[":path"]))

        api_pth = headers[":path"][len(API_ROOT_STAGING_data):]
        ns = DataHelpers.path_first_ns(api_pth)

        if ns == "ietf-netconf-acm":
            if username not in NACM_ADMINS:
                warn(username + " not allowed to access NACM data")
215
                http_resp = HttpResponse.empty(HttpStatus.Forbidden)
216
            else:
217
                http_resp = _get(ds.nacm.nacm_ds, username, api_pth, staging=True)
218
        else:
219
            http_resp = _get(ds, api_pth, username, staging=True)
220 221

        return http_resp
222 223 224 225

    return get_staging_api_closure


226
def get_file(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
Pavel Spirek's avatar
Pavel Spirek committed
227
    # Ordinary file on filesystem
228
    username = CertHelpers.get_field(client_cert, "emailAddress")
229 230 231
    url_path = headers[":path"].split("?")[0]
    url_path_safe = "".join(filter(lambda c: c.isalpha() or c in "/-_.", url_path)).replace("..", "").strip("/")
    file_path = os.path.join(CONFIG_HTTP["DOC_ROOT"], url_path_safe)
Pavel Spirek's avatar
Pavel Spirek committed
232 233 234 235

    if os.path.isdir(file_path):
        file_path = os.path.join(file_path, CONFIG_HTTP["DOC_DEFAULT_NAME"])

236
    ctype = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
Pavel Spirek's avatar
Pavel Spirek committed
237 238 239 240 241 242

    try:
        fd = open(file_path, 'rb')
        response = fd.read()
        fd.close()
    except FileNotFoundError:
Pavel Spirek's avatar
Pavel Spirek committed
243
        warn("[{}] Cannot open requested file \"{}\"".format(username, file_path))
244 245 246 247
        http_resp = HttpResponse.empty(HttpStatus.NotFound)
    else:
        info("[{}] Serving ordinary file {} of type \"{}\"".format(username, file_path, ctype))
        http_resp = HttpResponse(HttpStatus.Ok, response, ctype)
Pavel Spirek's avatar
Pavel Spirek committed
248

249
    return http_resp
Pavel Spirek's avatar
Pavel Spirek committed
250 251


252
def _post(ds: BaseDatastore, pth: str, username: str, data: str) -> HttpResponse:
253
    debug_httph("HTTP data received: " + data)
Pavel Spirek's avatar
Pavel Spirek committed
254

255 256 257 258 259 260 261
    url_split = pth.split("?")
    url_path = url_split[0]
    if len(url_split) > 1:
        query_string = parse_qs(url_split[1])
    else:
        query_string = {}

262
    rpc1 = RpcInfo()
263
    rpc1.username = username
264
    rpc1.path = url_path.rstrip("/")
265
    rpc1.qs = query_string
266

Pavel Spirek's avatar
Pavel Spirek committed
267
    try:
268
        json_data = json.loads(data) if len(data) > 0 else {}
Pavel Spirek's avatar
Pavel Spirek committed
269
    except ValueError as e:
Pavel Spirek's avatar
Pavel Spirek committed
270
        error("Failed to parse POST data: " + epretty(e))
271
        return HttpResponse.empty(HttpStatus.BadRequest)
272 273 274

    try:
        ds.lock_data(username)
275
        new_root = ds.create_node_rpc(ds.get_data_root_staging(rpc1.username), rpc1, json_data)
Pavel Spirek's avatar
Pavel Spirek committed
276
        ds.add_to_journal_rpc(ChangeType.CREATE, rpc1, json_data, new_root)
277
        http_resp = HttpResponse.empty(HttpStatus.Created)
278
    except DataLockError as e:
Pavel Spirek's avatar
Pavel Spirek committed
279
        warn(epretty(e))
280
        http_resp = HttpResponse.empty(HttpStatus.InternalServerError)
281
    except NacmForbiddenError as e:
Pavel Spirek's avatar
Pavel Spirek committed
282
        warn(epretty(e))
283 284
        http_resp = HttpResponse.empty(HttpStatus.Forbidden)
    except (NonexistentSchemaNode, NonexistentInstance) as e:
Pavel Spirek's avatar
Pavel Spirek committed
285
        warn(epretty(e))
286
        http_resp = HttpResponse.empty(HttpStatus.NotFound)
287
    except (InstanceValueError, YangTypeError, NoHandlerError, ValueError) as e:
288
        warn(epretty(e))
289
        http_resp = HttpResponse.empty(HttpStatus.BadRequest)
290 291
    except InstanceAlreadyPresent as e:
        warn(epretty(e))
292
        http_resp = HttpResponse.empty(HttpStatus.Conflict)
293 294 295
    finally:
        ds.unlock_data()

296 297
    return http_resp

298 299

def create_post_api(ds: BaseDatastore):
300 301
    def post_api_closure(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
        username = CertHelpers.get_field(client_cert, "emailAddress")
Pavel Spirek's avatar
Pavel Spirek committed
302
        info("[{}] api_post: {}".format(username, headers[":path"]))
303

304
        api_pth = headers[":path"][len(API_ROOT_data):]
305 306 307 308 309
        ns = DataHelpers.path_first_ns(api_pth)

        if ns == "ietf-netconf-acm":
            if username not in NACM_ADMINS:
                warn(username + " not allowed to access NACM data")
310
                http_resp = HttpResponse.empty(HttpStatus.Forbidden)
311
            else:
312
                http_resp = _post(ds.nacm.nacm_ds, api_pth, username, data)
313 314
                ds.nacm.update()
        else:
315 316 317
            http_resp = _post(ds, api_pth, username, data)

        return http_resp
318 319 320 321

    return post_api_closure


322
def _put(ds: BaseDatastore, pth: str, username: str, data: str) -> HttpResponse:
323
    debug_httph("HTTP data received: " + data)
324 325 326 327

    url_split = pth.split("?")
    url_path = url_split[0]

328
    rpc1 = RpcInfo()
329
    rpc1.username = username
330
    rpc1.path = url_path.rstrip("/")
331

Pavel Spirek's avatar
Pavel Spirek committed
332
    try:
333
        json_data = json.loads(data) if len(data) > 0 else {}
Pavel Spirek's avatar
Pavel Spirek committed
334 335
    except ValueError as e:
        error("Failed to parse PUT data: " + epretty(e))
336
        return HttpResponse.empty(HttpStatus.BadRequest)
337 338 339

    try:
        ds.lock_data(username)
340
        new_root = ds.update_node_rpc(ds.get_data_root_staging(rpc1.username), rpc1, json_data)
Pavel Spirek's avatar
Pavel Spirek committed
341
        ds.add_to_journal_rpc(ChangeType.REPLACE, rpc1, json_data, new_root)
342
        http_resp = HttpResponse.empty(HttpStatus.NoContent, status_in_body=False)
343
    except DataLockError as e:
Pavel Spirek's avatar
Pavel Spirek committed
344
        warn(epretty(e))
345
        http_resp = HttpResponse.empty(HttpStatus.InternalServerError)
346
    except NacmForbiddenError as e:
Pavel Spirek's avatar
Pavel Spirek committed
347
        warn(epretty(e))
348 349
        http_resp = HttpResponse.empty(HttpStatus.Forbidden)
    except (NonexistentSchemaNode, NonexistentInstance) as e:
Pavel Spirek's avatar
Pavel Spirek committed
350
        warn(epretty(e))
351
        http_resp = HttpResponse.empty(HttpStatus.NotFound)
Pavel Spirek's avatar
Pavel Spirek committed
352 353
    except NoHandlerError as e:
        warn(epretty(e))
354
        http_resp = HttpResponse.empty(HttpStatus.BadRequest)
355 356 357
    finally:
        ds.unlock_data()

358 359
    return http_resp

360 361

def create_put_api(ds: BaseDatastore):
362 363
    def put_api_closure(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
        username = CertHelpers.get_field(client_cert, "emailAddress")
Pavel Spirek's avatar
Pavel Spirek committed
364
        info("[{}] api_put: {}".format(username, headers[":path"]))
365

366
        api_pth = headers[":path"][len(API_ROOT_data):]
367 368 369 370 371
        ns = DataHelpers.path_first_ns(api_pth)

        if ns == "ietf-netconf-acm":
            if username not in NACM_ADMINS:
                warn(username + " not allowed to access NACM data")
372
                http_resp = HttpResponse.empty(HttpStatus.Forbidden)
373
            else:
374
                http_resp = _put(ds.nacm.nacm_ds, api_pth, username, data)
375 376
                ds.nacm.update()
        else:
377 378 379
            http_resp = _put(ds, api_pth, username, data)

        return http_resp
380 381 382

    return put_api_closure

Pavel Spirek's avatar
Pavel Spirek committed
383

384
def _delete(ds: BaseDatastore, pth: str, username: str) -> HttpResponse:
385 386
        url_split = pth.split("?")
        url_path = url_split[0]
Pavel Spirek's avatar
Pavel Spirek committed
387

388
        rpc1 = RpcInfo()
Pavel Spirek's avatar
Pavel Spirek committed
389
        rpc1.username = username
390
        rpc1.path = url_path.rstrip("/")
Pavel Spirek's avatar
Pavel Spirek committed
391 392

        try:
393
            ds.lock_data(username)
394
            new_root = ds.delete_node_rpc(ds.get_data_root_staging(rpc1.username), rpc1)
Pavel Spirek's avatar
Pavel Spirek committed
395
            ds.add_to_journal_rpc(ChangeType.DELETE, rpc1, None, new_root)
396
            http_resp = HttpResponse.empty(HttpStatus.NoContent, status_in_body=False)
Pavel Spirek's avatar
Pavel Spirek committed
397
        except DataLockError as e:
Pavel Spirek's avatar
Pavel Spirek committed
398
            warn(epretty(e))
399
            http_resp = HttpResponse.empty(HttpStatus.InternalServerError)
Pavel Spirek's avatar
Pavel Spirek committed
400
        except NacmForbiddenError as e:
Pavel Spirek's avatar
Pavel Spirek committed
401
            warn(epretty(e))
402 403
            http_resp = HttpResponse.empty(HttpStatus.Forbidden)
        except (NonexistentSchemaNode, NonexistentInstance) as e:
Pavel Spirek's avatar
Pavel Spirek committed
404
            warn(epretty(e))
405
            http_resp = HttpResponse.empty(HttpStatus.NotFound)
Pavel Spirek's avatar
Pavel Spirek committed
406 407
        except NoHandlerError as e:
            warn(epretty(e))
408
            http_resp = HttpResponse.empty(HttpStatus.BadRequest)
409
        finally:
410
            ds.unlock_data()
Pavel Spirek's avatar
Pavel Spirek committed
411

412 413
        return http_resp

414 415

def create_api_delete(ds: BaseDatastore):
416 417
    def api_delete_closure(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
        username = CertHelpers.get_field(client_cert, "emailAddress")
Pavel Spirek's avatar
Pavel Spirek committed
418
        info("[{}] api_delete: {}".format(username, headers[":path"]))
419

420
        api_pth = headers[":path"][len(API_ROOT_data):]
421 422 423 424 425
        ns = DataHelpers.path_first_ns(api_pth)

        if ns == "ietf-netconf-acm":
            if username not in NACM_ADMINS:
                warn(username + " not allowed to access NACM data")
426
                http_resp = HttpResponse.empty(HttpStatus.Forbidden)
427
            else:
428
                http_resp = _delete(ds.nacm.nacm_ds, api_pth, username)
429 430
                ds.nacm.update()
        else:
431 432 433
            http_resp = _delete(ds, api_pth, username)

        return http_resp
434 435

    return api_delete_closure
436 437 438


def create_api_op(ds: BaseDatastore):
439 440
    def api_op_closure(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
        username = CertHelpers.get_field(client_cert, "emailAddress")
Pavel Spirek's avatar
Pavel Spirek committed
441
        info("[{}] invoke_op: {}".format(username, headers[":path"]))
442

443 444
        api_pth = headers[":path"][len(API_ROOT_ops):]
        op_name_fq = api_pth[1:]
445 446
        op_name_splitted = op_name_fq.split(":", maxsplit=1)

447 448 449 450
        try:
            ns = op_name_splitted[0]
            op_name = op_name_splitted[1]
        except IndexError:
451
            warn("Operation name must be in fully-qualified format")
452
            return HttpResponse.empty(HttpStatus.BadRequest)
453 454

        try:
455
            json_data = json.loads(data) if len(data) > 0 else {}
456
        except ValueError as e:
Pavel Spirek's avatar
Pavel Spirek committed
457
            error("Failed to parse POST data: " + epretty(e))
458
            return HttpResponse.empty(HttpStatus.BadRequest)
459 460 461

        input_args = json_data.get(ns + ":input")

462
        rpc1 = RpcInfo()
463 464
        rpc1.username = username
        rpc1.path = api_pth
465
        rpc1.op_name = op_name_fq
466 467 468 469 470 471 472 473 474
        rpc1.op_input_args = input_args

        # Skip NACM check for privileged users
        if username in NACM_ADMINS:
            rpc1.skip_nacm_check = True

        try:
            ret_data = ds.invoke_op_rpc(rpc1)
            if ret_data is None:
475
                http_resp = HttpResponse.empty(HttpStatus.NoContent, status_in_body=False)
476
            else:
477 478 479 480
                if not isinstance(ret_data, str):
                    response = json.dumps(ret_data, indent=4)
                else:
                    response = ret_data
481
                http_resp = HttpResponse(HttpStatus.Ok, response.encode(), CT_YANG_JSON)
482 483
        except NacmForbiddenError as e:
            warn(epretty(e))
484
            http_resp = HttpResponse.empty(HttpStatus.Forbidden)
485 486
        except NonexistentSchemaNode as e:
            warn(epretty(e))
487 488
            http_resp = HttpResponse.empty(HttpStatus.NotFound)
        except (InstanceAlreadyPresent, NoHandlerForOpError, ValueError) as e:
Pavel Spirek's avatar
Pavel Spirek committed
489
            warn(epretty(e))
490
            http_resp = HttpResponse.empty(HttpStatus.BadRequest)
491
        except ConfHandlerFailedError as e:
Pavel Spirek's avatar
Pavel Spirek committed
492
            error(epretty(e))
493 494 495
            http_resp = HttpResponse.empty(HttpStatus.InternalServerError)

        return http_resp
496 497

    return api_op_closure
498 499 500 501 502 503 504 505 506 507


def options_api(headers: OrderedDict, data: Optional[str], client_cert: SSLCertT) -> HttpResponse:
    info("api_options: {}".format(headers[":path"]))
    headers_extra = OrderedDict()
    headers_extra["Allow"] = "GET, PUT, POST, OPTIONS, HEAD, DELETE"
    http_resp = HttpResponse(HttpStatus.Ok, bytes(), CT_PLAIN, extra_headers=headers_extra)

    return http_resp