diff --git a/components/neutron/aio-values.yaml b/components/neutron/aio-values.yaml index 5e8533d6..9b6a70ed 100644 --- a/components/neutron/aio-values.yaml +++ b/components/neutron/aio-values.yaml @@ -58,6 +58,8 @@ conf: # we aren't using availability zones so having calls attempt to add things to # availability zones won't work. default_availability_zones: "" + service_providers: + service_provider: "L3_ROUTER_NAT:cisco-asa:neutron_understack.l3_service_cisco_asa.CiscoAsa" # disable the neutron-ironic-agent from loading a non-existent config pod: diff --git a/python/neutron-understack/neutron_understack/cisco_asa.py b/python/neutron-understack/neutron_understack/cisco_asa.py new file mode 100644 index 00000000..539fde41 --- /dev/null +++ b/python/neutron-understack/neutron_understack/cisco_asa.py @@ -0,0 +1,108 @@ +# Copyright (c) 2024 Rackspace Technology +# All Rights Reserved. +# +# 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. + +# This aims to provide a basic abstraction of Cisco ASA ASDM commands +# needed to support a basic router and floating IP. Anything more +# should really use ntc-templates + +import ssl +from urllib.parse import quote_plus + +import requests +import urllib3 +from oslo_log import log as logging + +LOG = logging.getLogger(__name__) + + +class _CustomHttpAdapter(requests.adapters.HTTPAdapter): + """Custom adapter for bad ASA SSL.""" + + def __init__(self, ssl_context=None, **kwargs): + """Init to match requests HTTPAdapter.""" + self.ssl_context = ssl_context + super().__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = urllib3.poolmanager.PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + ssl_context=self.ssl_context, + ) + + +def _get_legacy_session(): + """Support bad ASA SSL.""" + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx.check_hostname = False + ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT + session = requests.session() + session.mount("https://", _CustomHttpAdapter(ctx)) + return session + + +def _cmd_str(cmds: list[str]) -> str: + """Handles encoding of a list of commands to the URL string.""" + encoded_cmds = [quote_plus(cmd) for cmd in cmds] + return "/".join(encoded_cmds) + + +class CiscoAsaAsdm: + def __init__( + self, mgmt_url: str, username: str, password: str, user_agent: str + ) -> None: + self.mgmt_url = mgmt_url + self.s = _get_legacy_session() + self.s.headers.update({"User-Agent": user_agent}) + self.s.auth = requests.auth.HTTPBasicAuth(username, password) + self.s.verify = False # these things are gross + + def _make_url(self, cmd_str: str) -> str: + return f"{self.mgmt_url}/admin/exec/{cmd_str}" + + def _make_request(self, op: str, cmds: list[str]) -> bool: + url = self._make_url(_cmd_str(cmds)) + LOG.debug("Cisco ASA ASDM request(%s): %s", op, url) + try: + r = self.s.get(url, timeout=20) + except Exception: + LOG.exception("Failed on %s", url) + return False + + LOG.debug("ASA response: %d / %s", r.status_code, r.text) + return True + + def create_nat( + self, + float_ip_addr: str, + asa_outside_inf: str, + inside_ip_addr: str, + asa_inside_inf: str, + ) -> bool: + cmds = [ + f"object network OBJ-{inside_ip_addr}", + f"host {inside_ip_addr}", + f"nat ({asa_inside_inf},{asa_outside_inf}) static {float_ip_addr}", + ] + + return self._make_request("create_nat", cmds) + + def delete_nat(self, inside_ip_addr: str) -> bool: + cmds = [ + f"no object network OBJ-{inside_ip_addr}", + ] + + return self._make_request("delete_nat", cmds) diff --git a/python/neutron-understack/neutron_understack/config.py b/python/neutron-understack/neutron_understack/config.py index 75c68f68..6d4749d9 100644 --- a/python/neutron-understack/neutron_understack/config.py +++ b/python/neutron-understack/neutron_understack/config.py @@ -45,6 +45,27 @@ ), ] +l3_svc_cisco_asa_opts = [ + cfg.StrOpt( + "user_agent", + help="User-Agent for requests to Cisco ASA", + default="ASDM", + ), + cfg.StrOpt( + "username", + help="username for requests to the Cisco ASA", + ), + cfg.StrOpt( + "password", + help="password for requests to the Cisco ASA", + ), + cfg.StrOpt( + "outside_interface", + help="ASA interface for outside connections", + default="OUTSIDE", + ), +] + def register_ml2_type_understack_opts(config): config.register_opts(type_understack_opts, "ml2_type_understack") @@ -52,3 +73,7 @@ def register_ml2_type_understack_opts(config): def register_ml2_understack_opts(config): config.register_opts(mech_understack_opts, "ml2_understack") + + +def register_l3_svc_cisco_asa_opts(config): + config.register_opts(l3_svc_cisco_asa_opts, "l3_service_cisco_asa") diff --git a/python/neutron-understack/neutron_understack/l3_service_cisco_asa.py b/python/neutron-understack/neutron_understack/l3_service_cisco_asa.py new file mode 100644 index 00000000..32b82a45 --- /dev/null +++ b/python/neutron-understack/neutron_understack/l3_service_cisco_asa.py @@ -0,0 +1,135 @@ +# inspired from +# https://docs.openstack.org/neutron/latest/admin/config-router-flavor-ovn.html + +from neutron.services.l3_router.service_providers import base +from neutron_lib.callbacks import events +from neutron_lib.callbacks import registry +from neutron_lib.callbacks import resources +from neutron_lib.plugins import directory +from oslo_config import cfg +from oslo_log import log as logging + +from neutron_understack import config +from neutron_understack.cisco_asa import CiscoAsaAsdm + +LOG = logging.getLogger(__name__) +config.register_l3_svc_cisco_asa_opts(cfg.CONF) + + +@registry.has_registry_receivers +class CiscoAsa(base.L3ServiceProvider): + use_integrated_agent_scheduler = True + + def __init__(self, l3plugin): + super().__init__(l3plugin) + self.core_plugin = directory.get_plugin() + + @registry.receives(resources.ROUTER_INTERFACE, [events.AFTER_CREATE]) + def _process_router_interface_create(self, resource, event, trigger, payload): + router = payload.states[0] + context = payload.context + port = payload.metadata["port"] + subnets = payload.metadata["subnets"] + LOG.debug( + "router_interface_create1 %s / %s / %s / %s", router, context, port, subnets + ) + LOG.debug( + "router_interface_create2 %s / %s / %s / %s", + resource, + event, + trigger, + payload, + ) + + @registry.receives(resources.FLOATING_IP, [events.AFTER_CREATE]) + def _process_floatingip_create(self, resource, event, trigger, payload): + LOG.debug( + "floatingip_create %s / %s / %s / %s", resource, event, trigger, payload + ) + + @registry.receives(resources.FLOATING_IP, [events.AFTER_UPDATE]) + def _process_floatingip_update(self, resource, event, trigger, payload): + conf = cfg.CONF.l3_service_cisco_asa + + # read the state, state[0] is previous and state[1] is current + context = payload.context + # are we associating (True) or disassociating (False) + assoc_disassoc = payload.metadata["association_event"] + # associating we want the current state while disassociating + # we want the previous + fip = payload.states[1] if assoc_disassoc else payload.states[0] + + # what is the floating IP we are trying to use + float_ip_addr = fip["floating_ip_address"] + # what is the router ID + router_id = fip["router_id"] + # inside IP + inside_ip_addr = fip["fixed_ip_address"] + inside_port_info = fip["port_details"] + asa_inside_inf = None + asa_outside_inf = conf.outside_interface + if inside_port_info: + # we will use the UUID of the network as our internal interface name + asa_inside_inf = inside_port_info["network_id"] + + # Since our network blocks need to be routed to the firewalls + # explicitly we'll store information about which firewall in the + # floating IP's network rather than the router object. The real + # behavior should be that the router object maps to the firewall + # but in this case the network is likely more correct. Plus + # the network is 'external' and cannot be mucked with by a + # normal user. + LOG.debug( + "Looking up floating IP's network (%s) description", + fip["floating_network_id"], + ) + if not fip["floating_network_id"]: + return + try: + float_ip_net = self.core_plugin.get_network( + context, fip["floating_network_id"], fields=["description"] + ) + except Exception: + LOG.exception( + "Unable to lookup floating IP's network %s", fip["floating_network_id"] + ) + return + + try: + asa_mgmt = float_ip_net["description"].split("=")[-1] + except Exception: + LOG.exception( + "Unable to parse firewall mgmt IP and port from floating IP " + "network description" + ) + return + + action_msg = "associate" if assoc_disassoc else "disassociate" + + LOG.debug( + "Request to %s floating IP %s via router %s/%s/%s to %s on %s", + action_msg, + float_ip_addr, + router_id, + asa_mgmt, + asa_outside_inf, + inside_ip_addr, + asa_inside_inf, + ) + + if asa_mgmt and asa_inside_inf and inside_ip_addr and float_ip_addr: + asa = CiscoAsaAsdm( + f"https://{asa_mgmt}", conf.username, conf.password, conf.user_agent + ) + if assoc_disassoc: + ret = asa.create_nat( + float_ip_addr, asa_outside_inf, inside_ip_addr, asa_inside_inf + ) + else: + ret = asa.delete_nat(inside_ip_addr) + + if not ret: + LOG.error( + "Unable to make change on ASA device for router %s", + fip["router_id"], + ) diff --git a/python/neutron-understack/pyproject.toml b/python/neutron-understack/pyproject.toml index 1887ae5f..38991326 100644 --- a/python/neutron-understack/pyproject.toml +++ b/python/neutron-understack/pyproject.toml @@ -87,3 +87,4 @@ understack_vxlan = "neutron_understack.type_understack_vxlan:UnderstackVxlanType [tool.poetry.plugins."neutron.service_plugins"] l3_understack = "neutron_understack.l3_service_plugin:UnderStackL3ServicePlugin" +l3_service_cisco_asa = "neutron_understack.l3_service_cisco_asa:CiscoAsa"