Commit 25ba9b2b authored by Lluis Gifre Renom's avatar Lluis Gifre Renom
Browse files

NBI - IETF ACL connector:

- Added YANG Validation with LibYang
- Corrected POST logic to support multiple ACL Entries and interfaces
- Corrected unitary tests
parent 6d9f2b91
Loading
Loading
Loading
Loading
+36 −61
Original line number Diff line number Diff line
@@ -12,89 +12,64 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
import re
import json

import json, logging, re
from flask_restful import Resource
from werkzeug.exceptions import NotFound

from nbi.service.rest_server.nbi_plugins.tools.Authentication import HTTP_AUTH
from common.proto.acl_pb2 import AclRuleTypeEnum
from common.proto.context_pb2 import (
    ConfigActionEnum,
    ConfigRule,
    Device,
    DeviceId,
)
from common.tools.object_factory.Device import json_device_id
from common.tools.grpc.Tools import grpc_message_to_json_string
from common.proto.context_pb2 import ConfigActionEnum, ConfigRule
from common.tools.context_queries.Device import get_device
from context.client.ContextClient import ContextClient
from device.client.DeviceClient import DeviceClient


from nbi.service.rest_server.nbi_plugins.tools.Authentication import HTTP_AUTH
from .ietf_acl_parser import ietf_acl_from_config_rule_resource_value

LOGGER = logging.getLogger(__name__)

ACL_CONIG_RULE_KEY = r'\/device\[.+\]\/endpoint\[(.+)\]/acl_ruleset\[{}\]'


class ACL(Resource):
    # @HTTP_AUTH.login_required
class Acl(Resource):
    @HTTP_AUTH.login_required
    def get(self, device_uuid : str, acl_name : str):
        LOGGER.debug("GET device_uuid={:s}, acl_name={:s}".format(str(device_uuid), str(acl_name)))
        LOGGER.debug('GET device_uuid={:s}, acl_name={:s}'.format(str(device_uuid), str(acl_name)))
        RE_ACL_CONIG_RULE_KEY = re.compile(ACL_CONIG_RULE_KEY.format(acl_name))

        context_client = ContextClient()
        device_client = DeviceClient()

        _device = context_client.GetDevice(DeviceId(**json_device_id(device_uuid)))


        for cr in _device.device_config.config_rules:
            if cr.WhichOneof('config_rule') == 'custom':
                if ep_uuid_match := RE_ACL_CONIG_RULE_KEY.match(cr.custom.resource_key):
                    endpoint_uuid = ep_uuid_match.groups(0)[0]
                    resource_value_dict = json.loads(cr.custom.resource_value)
                    LOGGER.debug(f'P99: {resource_value_dict}')
        device = get_device(context_client, device_uuid, rw_copy=False, include_config_rules=True)
        if device is None: raise NotFound('Device({:s}) not found'.format(str(device_uuid)))

        for config_rule in device.device_config.config_rules:
            if config_rule.WhichOneof('config_rule') != 'custom': continue
            ep_uuid_match = RE_ACL_CONIG_RULE_KEY.match(config_rule.custom.resource_key)
            if ep_uuid_match is None: continue
            resource_value_dict = json.loads(config_rule.custom.resource_value)
            return ietf_acl_from_config_rule_resource_value(resource_value_dict)
        else:
            raise NotFound(f'ACL not found')

    # @HTTP_AUTH.login_required
        raise NotFound('Acl({:s}) not found in Device({:s})'.format(str(acl_name), str(device_uuid)))

    @HTTP_AUTH.login_required
    def delete(self, device_uuid : str, acl_name : str):
        LOGGER.debug("DELETE device_uuid={:s}, acl_name={:s}".format(str(device_uuid), str(acl_name)))
        LOGGER.debug('DELETE device_uuid={:s}, acl_name={:s}'.format(str(device_uuid), str(acl_name)))
        RE_ACL_CONIG_RULE_KEY = re.compile(ACL_CONIG_RULE_KEY.format(acl_name))

        context_client = ContextClient()
        device_client = DeviceClient()

        _device = context_client.GetDevice(DeviceId(**json_device_id(device_uuid)))
        device = get_device(context_client, device_uuid, rw_copy=True, include_config_rules=True)
        if device is None: raise NotFound('Device({:s}) not found'.format(str(device_uuid)))

        delete_config_rules = list()
        for config_rule in device.device_config.config_rules:
            if config_rule.WhichOneof('config_rule') != 'custom': continue
            ep_uuid_match = RE_ACL_CONIG_RULE_KEY.match(config_rule.custom.resource_key)
            if ep_uuid_match is None: continue

        for cr in _device.device_config.config_rules:
            if cr.WhichOneof('config_rule') == 'custom':
                if ep_uuid_match := RE_ACL_CONIG_RULE_KEY.match(cr.custom.resource_key):
                    endpoint_uuid = ep_uuid_match.groups(0)[0]
                    resource_value_dict = json.loads(cr.custom.resource_value)
                    type_str = resource_value_dict['rule_set']['type']
                    interface = resource_value_dict['interface']
                    break
        else:
            raise NotFound(f'ACL not found')
            _config_rule = ConfigRule()
            _config_rule.CopyFrom(config_rule)
            _config_rule.action = ConfigActionEnum.CONFIGACTION_DELETE
            delete_config_rules.append(_config_rule)

        acl_config_rule = ConfigRule()
        acl_config_rule.action = ConfigActionEnum.CONFIGACTION_DELETE
        acl_config_rule.acl.rule_set.name = acl_name
        acl_config_rule.acl.interface = interface
        acl_config_rule.acl.rule_set.type = getattr(AclRuleTypeEnum, type_str)
        acl_config_rule.acl.endpoint_id.device_id.device_uuid.uuid = device_uuid
        acl_config_rule.acl.endpoint_id.endpoint_uuid.uuid = endpoint_uuid
        if len(delete_config_rules) == 0:
            raise NotFound('Acl({:s}) not found in Device({:s})'.format(str(acl_name), str(device_uuid)))

        device = Device()
        device.CopyFrom(_device)
        device_client = DeviceClient()
        del device.device_config.config_rules[:]
        device.device_config.config_rules.append(acl_config_rule)
        response = device_client.ConfigureDevice(device)
        return (response.device_uuid.uuid).strip("\"\n")
        device.device_config.config_rules.extend(delete_config_rules)
        device_client.ConfigureDevice(device)
        return None
+131 −0
Original line number Diff line number Diff line
# Copyright 2022-2024 ETSI OSG/SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json, logging
from typing import Dict, List, Set
from flask import jsonify, request
from flask_restful import Resource
from werkzeug.exceptions import BadRequest, NotFound, UnsupportedMediaType
from common.proto.context_pb2 import ConfigRule
from common.tools.context_queries.Device import get_device
from common.tools.grpc.Tools import grpc_message_to_json_string
from context.client.ContextClient import ContextClient
from device.client.DeviceClient import DeviceClient
from nbi.service.rest_server.nbi_plugins.tools.Authentication import HTTP_AUTH
from .ietf_acl_parser import AclDirectionEnum, config_rule_from_ietf_acl
from .YangValidator import YangValidator

LOGGER = logging.getLogger(__name__)


def compose_interface_direction_acl_rules(
    device_name : str, interface_name : str, interface_data : Dict,
    acl_direction : AclDirectionEnum, acl_name__to__acl_data : Dict[str, Dict]
) -> List[ConfigRule]:
    acl_direction_name  = acl_direction.value
    acl_direction_title = str(acl_direction_name).title()
    direction_data : Dict[str, Dict] = interface_data.get(acl_direction_name, {})
    acl_sets       : Dict[str, Dict] = direction_data.get('acl-sets',         {})
    acl_set_list   : List[Dict]      = acl_sets      .get('acl-set',          [])
    acl_set_names  : Set[str]        = {acl_set['name'] for acl_set in acl_set_list}

    acl_config_rules : List[ConfigRule] = list()
    for acl_set_name in acl_set_names:
        acl_set = acl_name__to__acl_data.get(acl_set_name)
        if acl_set is None:
            MSG = 'Interface({:s})/{:s}/AclSet({:s}) not found'
            raise NotFound(MSG.format(
                str(interface_name), acl_direction_title,
                str(acl_set_name)
            ))

        acl_config_rule = config_rule_from_ietf_acl(
            device_name, interface_name, acl_set
        )
        MSG = 'Adding {:s} ACL Config Rule: {:s}'
        LOGGER.info(MSG.format(
            acl_direction_title, grpc_message_to_json_string(acl_config_rule)
        ))
        acl_config_rules.append(acl_config_rule)

    return acl_config_rules

class Acls(Resource):
    @HTTP_AUTH.login_required
    def get(self):
        return {}

    @HTTP_AUTH.login_required
    def post(self, device_uuid : str):
        if not request.is_json:
            LOGGER.warning('POST device_uuid={:s}, body={:s}'.format(str(device_uuid), str(request.data)))
            raise UnsupportedMediaType('JSON payload is required')
        request_data : Dict = request.json
        LOGGER.debug('POST device_uuid={:s}, body={:s}'.format(str(device_uuid), json.dumps(request_data)))

        context_client = ContextClient()
        device = get_device(
            context_client, device_uuid, rw_copy=True, include_config_rules=False, include_components=False
        )
        if device is None:
            raise NotFound('Device({:s}) not found'.format(str(device_uuid)))

        device_name = device.name
        interface_names : Set[str] = set()
        for endpoint in device.device_endpoints:
            interface_names.add(endpoint.endpoint_id.endpoint_uuid.uuid)
            interface_names.add(endpoint.name)

        yang_validator = YangValidator()
        request_data = yang_validator.parse_to_dict(request_data, list(interface_names))
        yang_validator.destroy()

        acls          : Dict = request_data.get('acls', {})
        acl_list      : List = acls.get('acl', [])
        acl_name__to__acl_data = {
            acl['name'] : acl
            for acl in acl_list
        }

        if len(acl_name__to__acl_data) == 0:
            raise BadRequest('No ACLs defined in the request')

        interface_list : List = acls.get('attachment-points', {}).get('interface', [])
        interface_name__to__interface_data = {
            interface['interface-id'] : interface
            for interface in interface_list
        }

        if len(interface_name__to__interface_data) == 0:
            raise BadRequest('No interfaces defined in the request')

        for interface_name in interface_names:
            interface_data = interface_name__to__interface_data.get(interface_name)
            if interface_data is None: continue

            ingress_acl_config_rules = compose_interface_direction_acl_rules(
                device_name, interface_name, interface_data, AclDirectionEnum.INGRESS,
                acl_name__to__acl_data
            )
            device.device_config.config_rules.extend(ingress_acl_config_rules)

            egress_acl_config_rules = compose_interface_direction_acl_rules(
                device_name, interface_name, interface_data, AclDirectionEnum.EGRESS,
                acl_name__to__acl_data
            )
            device.device_config.config_rules.extend(egress_acl_config_rules)

        device_client = DeviceClient()
        device_client.ConfigureDevice(device)
        return jsonify({})
+111 −0
Original line number Diff line number Diff line
# Copyright 2022-2024 ETSI OSG/SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import copy, json, libyang, logging, os
from typing import Dict, List, Optional

LOGGER = logging.getLogger(__name__)

YANG_DIR = os.path.join(os.path.dirname(__file__), 'yang')
YANG_MODULES = [
    'ietf-yang-types',
    'ietf-interfaces',
    'iana-if-type',
    'ietf-access-control-list',
]

class YangValidator:
    def __init__(self) -> None:
        self._yang_context = libyang.Context(YANG_DIR)
        for module_name in YANG_MODULES:
            LOGGER.info('Loading module: {:s}'.format(str(module_name)))
            yang_module = self._yang_context.load_module(module_name)
            yang_module.feature_enable_all()

    def parse_to_dict(self, message : Dict, interface_names : List[str]) -> Dict:
        LOGGER.debug('[parse_to_dict] message={:s}'.format(json.dumps(message)))
        LOGGER.debug('[parse_to_dict] interface_names={:s}'.format(json.dumps(interface_names)))

        # Inject synthetic interfaces for validation purposes
        interfaces = self._yang_context.create_data_path('/ietf-interfaces:interfaces')
        for if_index,interface_name in enumerate(interface_names):
            if_path = 'interface[name="{:s}"]'.format(str(interface_name))
            interface = interfaces.create_path(if_path)
            interface.create_path('if-index', if_index + 1)
            interface.create_path('type', 'iana-if-type:ethernetCsmacd')
            interface.create_path('admin-status', 'up')
            interface.create_path('oper-status', 'up')
            statistics = interface.create_path('statistics')
            statistics.create_path('discontinuity-time', '2024-07-11T10:00:00.000000Z')

        extended_message = copy.deepcopy(message)
        extended_message['ietf-interfaces:interfaces'] = interfaces.print_dict()['interfaces']
        LOGGER.debug('[parse_to_dict] extended_message={:s}'.format(json.dumps(extended_message)))

        dnode : Optional[libyang.DNode] = self._yang_context.parse_data_mem(
            json.dumps(extended_message), 'json', validate_present=True, strict=True
        )
        if dnode is None:
            LOGGER.error('[parse_to_dict] unable to parse message')
            raise Exception('Unable to parse Message({:s})'.format(str(message)))
        message_dict = dnode.print_dict()
        LOGGER.debug('[parse_to_dict] message_dict={:s}'.format(json.dumps(message_dict)))

        dnode.free()
        interfaces.free()
        return message_dict

    def destroy(self) -> None:
        self._yang_context.destroy()
        self._yang_context = None

def main() -> None:
    import uuid # pylint: disable=import-outside-toplevel
    logging.basicConfig(level=logging.DEBUG)

    interface_names = {'200', '500', str(uuid.uuid4()), str(uuid.uuid4())}
    ACL_RULE = {"ietf-access-control-list:acls": {
        "acl": [{
            "name": "sample-ipv4-acl", "type": "ipv4-acl-type",
            "aces": {"ace": [{
                "name": "rule1",
                "matches": {
                    "ipv4": {
                        "source-ipv4-network": "128.32.10.6/24",
                        "destination-ipv4-network": "172.10.33.0/24",
                        "dscp": 18
                    },
                    "tcp": {
                        "source-port": {"operator": "eq", "port": 1444},
                        "destination-port": {"operator": "eq", "port": 1333},
                        "flags": "syn"
                    }
                },
                "actions": {"forwarding": "drop"}
            }]}
        }],
        "attachment-points": {"interface": [{
            "interface-id": "200",
            "ingress": {"acl-sets": {"acl-set": [{"name": "sample-ipv4-acl"}]}}
        }]
    }}}

    yang_validator = YangValidator()
    request_data = yang_validator.parse_to_dict(ACL_RULE, list(interface_names))
    yang_validator.destroy()

    LOGGER.info('request_data = {:s}'.format(str(request_data)))

if __name__ == '__main__':
    main()
+8 −11
Original line number Diff line number Diff line
@@ -13,29 +13,26 @@
# limitations under the License.

from flask_restful import Resource

from nbi.service.rest_server.nbi_plugins.ietf_acl.acl_service import ACL
from nbi.service.rest_server.nbi_plugins.ietf_acl.acl_services import ACLs
from nbi.service.rest_server.RestServer import RestServer
from .Acl import Acl
from .Acls import Acls

URL_PREFIX = "/restconf/data"

URL_PREFIX = '/restconf/data'

def __add_resource(rest_server: RestServer, resource: Resource, *urls, **kwargs):
    urls = [(URL_PREFIX + url) for url in urls]
    rest_server.add_resource(resource, *urls, **kwargs)


def register_ietf_acl(rest_server: RestServer):
    __add_resource(
        rest_server,
        ACLs,
        "/device=<path:device_uuid>/ietf-access-control-list:acls",
        Acls,
        '/device=<path:device_uuid>/ietf-access-control-list:acls',
    )

    __add_resource(
        rest_server,
        ACL,
        "/device=<path:device_uuid>/ietf-access-control-list:acl=<path:acl_name>",
        "/device=<path:device_uuid>/ietf-access-control-list:acl=<path:acl_name>/",
        Acl,
        '/device=<path:device_uuid>/ietf-access-control-list:acl=<path:acl_name>',
        '/device=<path:device_uuid>/ietf-access-control-list:acl=<path:acl_name>/',
    )
+0 −65
Original line number Diff line number Diff line
# Copyright 2022-2024 ETSI OSG/SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
from typing import Dict
from flask import request
from flask_restful import Resource
from werkzeug.exceptions import NotFound, UnsupportedMediaType
from common.proto.context_pb2 import Device, DeviceId
from common.tools.grpc.Tools import grpc_message_to_json_string
from common.tools.object_factory.Device import json_device_id
from context.client.ContextClient import ContextClient
from device.client.DeviceClient import DeviceClient
#from nbi.service.rest_server.nbi_plugins.tools.Authentication import HTTP_AUTH
from .ietf_acl_parser import config_rule_from_ietf_acl

LOGGER = logging.getLogger(__name__)

class ACLs(Resource):
    # @HTTP_AUTH.login_required
    def get(self):
        return {}

    # @HTTP_AUTH.login_required
    def post(self, device_uuid: str):
        LOGGER.debug("POST device_uuid={:s}, body={:s}".format(str(device_uuid), str(request.data)))
        if not request.is_json:
            raise UnsupportedMediaType("JSON pyload is required")
        request_data: Dict = request.json
        LOGGER.debug("Request: {:s}".format(str(request_data)))
        attached_interface = request_data["ietf-access-control-list"]["acls"]['attachment-points']['interface'][0]['interface-id']

        context_client = ContextClient()
        device_client = DeviceClient()

        _device = context_client.GetDevice(DeviceId(**json_device_id(device_uuid)))
        
        for ep in _device.device_endpoints:
            if ep.name == attached_interface:
                endpoint_uuid = ep.endpoint_id.endpoint_uuid.uuid
                break
        else:
            raise NotFound(f'interface {attached_interface} not found in device {device_uuid}')

        acl_config_rule = config_rule_from_ietf_acl(request_data, device_uuid, endpoint_uuid, sequence_id=1, subinterface=0)

        LOGGER.info(f"ACL Config Rule: {grpc_message_to_json_string(acl_config_rule)}")

        device = Device()
        device.CopyFrom(_device)
        del device.device_config.config_rules[:]
        device.device_config.config_rules.append(acl_config_rule)
        response = device_client.ConfigureDevice(device)
        return (response.device_uuid.uuid).strip("\"\n")
Loading