#!/usr/bin/python3

# Copyright (c) 2025, Thomas Goirand <zigo@debian.org>
#           (c) 2025, Siméon Gourlin <simeon.gourlin@infomaniak.com>
#
# 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 eventlet

eventlet.monkey_patch()

import ipaddress
from ipaddress import ip_network, ip_address
import logging as pylogging
import os
import signal
import socket
import sys
import threading
import time

from keystoneauth1 import loading as ks_loading
from openstack import connection
#from openstack.network.v2 import bgp_speaker
from os_ken.services.protocols.bgp.bgpspeaker import BGPSpeaker
from oslo_config import cfg
from oslo_log import log as logging
import oslo_messaging
from oslo_messaging import rpc, opts as messaging_opts

LOG = logging.getLogger(__name__,project='neutron-ipv6-bgp-injector')


common_opts = [
    cfg.ListOpt('subnet_list',
        default=['internal-v6-subnet1'],
        help='List of subnets to advertize ports from.'),
    cfg.ListOpt('bgp_peer_list',
        default=[],
        sample_default='123@10.0.1.1, 456@10.0.1.2',
        help='List of BGP peers to connect to, in the form ASN@IP.'),
    cfg.IntOpt('bgp_peer_reconnect_interval',
        default=15,
        help='Time in seconds between BGP peer re-connection attempts'),
    cfg.StrOpt('bgp_our_router_id',
        default='10.10.10.11',
        help='Our router ID. Usually matches our IP address'),
    cfg.IntOpt('bgp_our_as',
        default=64914,
        help='Our ASN'),
    cfg.StrOpt('bgp_speaker_id',
        default=None,
        sample_default='3a2c9f6a-8afa-4b33-8ef8-9f8dd4d4fc5b',
        help='UUID of a BGP speaker doing IPv6 advertizing. '
             'Currently unused, as nibi can now find the next '
             'HOP of subnets without using BGP speakers.'),
    cfg.StrOpt('neutron_notifications_exchange_name',
        default='notifications',
        help='Name of the exchange where to recieve notifications from Neutron.'),
    cfg.StrOpt('neutron_notifications_topic',
        default='#',
        help='Name of the topics to watch to. "#" means all topics.'),
]

def list_opts():
    """Return options to be picked up by oslo-config-generator."""
    auth_opts = ks_loading.get_auth_common_conf_options()
    session_opts = ks_loading.get_session_conf_options()
    adapter_opts = ks_loading.get_adapter_conf_options()

    neutron_opts = []
    for name in ks_loading.get_available_plugin_names():
        neutron_opts.extend(ks_loading.get_auth_plugin_conf_options(name))

    return [
        ("DEFAULT", common_opts),
        ("neutron", auth_opts + neutron_opts + session_opts + adapter_opts),
    ]

def sd_notify(state: str):
    """Send a notification to systemd."""
    notify_socket = os.getenv("NOTIFY_SOCKET")
    if not notify_socket:
        return False

    # Abstract namespace socket if starts with '@'
    if notify_socket[0] == '@':
        notify_socket = '\0' + notify_socket[1:]

    sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
    try:
        sock.connect(notify_socket)
        sock.sendall(state.encode())
    finally:
        sock.close()
    return True

def get_next_hop_for_subnet(conn, bgp_speaker_id, subnet_id):
    subnet = conn.network.get_subnet(subnet_id)
    cidr = subnet.cidr

    # We do this query like this because OpenStackSDK does
    # not have this facility in Victoria.
    url = f"{conn.network.get_endpoint()}/bgp-speakers/{bgp_speaker_id}/get_advertised_routes"
    resp = conn.session.get(url)
    routes = resp.json().get("advertised_routes", [])

    for route in routes:
        if route.get("destination") == cidr:
            return route.get("next_hop")

    return None


def get_reserved_ipv6_for_subnet(conn, subnet_id):
    """
    Returns a set of IPv6 addresses that should NOT be advertised for a given subnet:
    - Router gateway IPv6s
    - DHCP server IPv6s
    """
    reserved_ipv6 = set()

    # Router port(s) (default gateway)
    LOG.info(f"🔍 Searching for reserved IPs for subnet {subnet_id}.")
    router_ports = list(conn.network.ports(
        device_owner="network:ha_router_replicated_interface",
        fixed_ips=f"subnet_id={subnet_id}"
    ))
    for port in router_ports:
        for ip_info in port.fixed_ips:
            ip = ip_info.get("ip_address")
            if ip and ipaddress.ip_address(ip).version == 6:
                LOG.info(f"🔍 Found {ip} as ha_router: will not advertize it.")
                reserved_ipv6.add(ip)

    # DHCP ports
    dhcp_ports = list(conn.network.ports(
        device_owner="network:dhcp",
        fixed_ips=f"subnet_id={subnet_id}"
    ))
    for port in dhcp_ports:
        for ip_info in port.fixed_ips:
            ip = ip_info.get("ip_address")
            if ip and ipaddress.ip_address(ip).version == 6:
                LOG.info(f"🔍 Found {ip} as DHCP server: will not advertize it.")
                reserved_ipv6.add(ip)

    return reserved_ipv6



def get_router_gateway_ip_from_subnet(conn, subnet_id):
    try:
        # Equivalent of: router_port_id = openstack port list --device-owner 'network:ha_router_replicated_interface' --fixed-ip subnet=bdb1-vps-net1-subnet2 --format value -c ID
        LOG.info(f"🔍 Searching ports: openstack port list --device-owner 'network:ha_router_replicated_interface' --fixed-ip subnet={subnet_id}")

        router_ports = list(conn.network.ports(
            device_owner="network:ha_router_replicated_interface",
            fixed_ips=f"subnet_id={subnet_id}"
        ))

        if not router_ports:
            LOG.error(f"⧱ No router port found for subnet {subnet_id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
            return None

        if len(router_ports) > 1:
            LOG.warning(f"⚠ Found multiple ports for the router of {subnet_id}:")
            LOG.warning(router_ports)
            LOG.warning(f"⚠ Will try to use the first one only")

        router_port = router_ports[0]

        # Equivalent of: router_id = openstack port show --format value -c device_id $router_port_id
        LOG.info(f"🔍 Searching router ID for port {router_port.id}")
        port = conn.network.get_port(router_port.id)
        if not port or not port.device_id:
            LOG.error(f"⧱ No router_id found for port {router_port.id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
            return None

        router_id = port.device_id
        LOG.info(f"⚙ Found router ID: {router_id}")

        # Equivalent of: openstack router show $router_id --format json -c external_gateway_info | jq '.external_gateway_info.external_fixed_ips[].ip_address' -r | grep 2001
        LOG.info(f"🔍 Searching for external gateway info for router {router_id}")
        router = conn.network.get_router(router_id)
        if not router or not router.external_gateway_info:
            LOG.error(f"⧱ No external gateway info found for router {router_id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
            return None

        LOG.info(f"🔍 Searching IPv6 in the external_gw_info of router {router_id}")
        for ip_info in router.external_gateway_info.get("external_fixed_ips", []):
            ip = ip_info.get("ip_address")
            if ip and ipaddress.ip_address(ip).version == 6:
                return ip

        LOG.error(f"⧱ No IPv6 external gateway IP found for router {router_id}: exiting get_router_gateway_ip_from_subnet() for subnet {subnet_id}")
        return None

    except ResourceNotFound as e:
        LOG.error(f"⧱ Resource not found: {e}")
        return None
    except SDKException as e:
        LOG.error(f"⧱ OpenStack SDK error: {e}")
        return None
    except Exception as e:
        LOG.error(f"⧱ Unexpected error: {e}")
        return None

# ----------------------------------------------------------
# Query all currently reserved IPv6 of the subnet_list conf.
# ----------------------------------------------------------
def query_neutron_ipv6_next_hop(conf: cfg.ConfigOpts):
    # Load auth/session/adapter from [neutron] group
    auth    = ks_loading.load_auth_from_conf_options(conf, "neutron")
    sess    = ks_loading.load_session_from_conf_options(conf, "neutron", auth=auth)
    adapter = ks_loading.load_adapter_from_conf_options(conf, "neutron", session=sess, auth=auth)

    # Build OpenStackSDK Connection (so we can use network API)
    conn = connection.Connection(
        session=sess,
        region_name=adapter.region_name,
        interface=adapter.interface,
    )

    #########################################################
    ### Below, we're going to build these dict and arrays ###
    #########################################################
    # Map subnet names from conf to subnet IDs
    subnet_name_to_id_dict = {}
    # and subnet_id to their next HOP
    subnet_id_to_nexthop_dict = {}
    # List all ports that needs IPv6 advertizing.
    # This is temporary to this function only.
    ipv6_ports = []
    # All ports and their associated IPv6
    # Note: currently, this code only supports a single IPv6 per port.
    # This is the dict we're aiming to maintain during the lifetime of this daemon.
    port_id_to_ipv6_dict = {}
    # All IPv6 we're interested (ie: these in the subnet_list in our
    # configuration file) in and their next HOP, so we can do their BGP announce.
    ipv6_to_next_hop_dict = {}
    # Build a map: subnet_id -> reserved IPv6s
    subnet_id_to_reserved_ips = {}
    
    try:
        # We iterate on all configure subnet of the subnet_list
        # configured in our configuration file. Doing so, we
        # build the subnet_id_to_nexthop_dict dict, and we also
        # convert subnet names into IDs.
        subnet_names = conf.subnet_list
        for subnet in conn.network.subnets():
            if subnet.name in subnet_names:
                subnet_name_to_id_dict[subnet.name] = subnet.id
                # Find the next HOP for the subnet.
                # This done like this:
                # router_port_id = openstack port list --device-owner 'network:ha_router_replicated_interface' --fixed-ip subnet=bdb1-vps-net1-subnet2 --format value -c ID
                # router_id = openstack port show --format value -c device_id $router_port_id
                # openstack router show $router_id --format json -c external_gateway_info | jq '.external_gateway_info.external_fixed_ips[].ip_address' -r | grep 2001
                next_hop_for_subnet = get_router_gateway_ip_from_subnet(conn, subnet.id)

                # This other version asks neutron-dynamic-routing what's the next HOP for a subnet_id instead:
                #next_hop_for_subnet = get_next_hop_for_subnet(conn, conf.bgp_speaker_id, subnet.id)
                if next_hop_for_subnet == None:
                    LOG.error(f"⧱ Could not find next HOP for subnet: {subnet.id} (neutron replied None)")
                    LOG.error(f"⧱ Please make sure that this subnet is connected to a router configured with an external gateway.")
                    LOG.error(f"⧱ Will exist then...")
                    exit(1)
                else:
                    LOG.info(f"⚙ Found next HOP for {subnet.id}: {next_hop_for_subnet}")
                    subnet_id_to_nexthop_dict[subnet.id] = next_hop_for_subnet

        if not subnet_name_to_id_dict:
            LOG.error(f"⧱ No matching subnets found for names: {subnet_names}")
            return {}, {}, {}

    except Exception as e:
        import traceback
        traceback.print_exc()
        LOG.error(f"⧱ Error while fetching next HOP for subnets: {e}")

    try:
        # Add all ports of this subnet_id to the
        # ipv6_ports array.
        for subnet_id in subnet_name_to_id_dict.values():
            for port in conn.network.ports(fixed_ips=f"subnet_id={subnet_id}"):
                ipv6_ports.append(port)
    except Exception as e:
        import traceback
        traceback.print_exc()
        LOG.error(f"⧱ Error while fetching all ports for all configured subnets: {e}")

    try:
        # For each port, only add IPv6s if not reserved
        # Build a map: subnet_id -> reserved IPv6s
        for subnet_id in subnet_name_to_id_dict.values():
            subnet_id_to_reserved_ips[subnet_id] = get_reserved_ipv6_for_subnet(conn, subnet_id)

        # For all the ports collected above, add all of
        # their (eventual) IPv6 to the ipv6_to_next_hop_dict.
        for port in ipv6_ports:
            for ip_info in port.fixed_ips:
                ipv6_addr = ip_info["ip_address"]
                ipv6_subnet = ip_info["subnet_id"]
                if ipaddress.ip_address(ipv6_addr).version == 6 and ipv6_addr not in subnet_id_to_reserved_ips.get(ipv6_subnet, set()):
                    if ipv6_subnet in subnet_id_to_nexthop_dict:
                        ipv6_to_next_hop_dict[ipv6_addr] = subnet_id_to_nexthop_dict[ipv6_subnet]
                        port_id_to_ipv6_dict[port.id] = ipv6_addr
                        LOG.info(f"⚙ Port: {port.id} IPv6 addresses: {ipv6_addr}, subnet: {ipv6_subnet}")
    except Exception as e:
        import traceback
        traceback.print_exc()
        LOG.error(f"⧱ Error while parsing ip_info of all ports: {e}")

    return ipv6_to_next_hop_dict, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict


class BGPSpeakerManager:

    # This __init__ does:
    # * create a new BGPSpeaker object.
    # * parse the bgp_peer_list dict from config file.
    # * add all BGP peers listed in bgp_peer_list to the BGPSpeaker.
    def __init__(self, conf: cfg.ConfigOpts):
        self.conf = conf
        self.speaker = BGPSpeaker(
            as_number=conf.bgp_our_as,
            router_id=conf.bgp_our_router_id,
            bgp_server_port=0, # bgp_server_port=0 => listener disabled
        )
        self.injected_routes = {}  # to follow injected routes
        self.peers = []

        # --- robust parsing of bgp_peer_list ---
        # accept both comma-separated string or list from config
        raw_peers = conf.bgp_peer_list
        if isinstance(raw_peers, list):
            # join into one string, just in case
            raw_peers = ','.join(raw_peers)

        # split on commas, strip whitespace, ignore empty
        for entry in (e.strip() for e in raw_peers.split(',') if e.strip()):
            # entry must contain exactly one '@'
            if '@' not in entry:
                LOG.warning(f"⚠ Ignoring invalid bgp_peer_list entry: '{entry}' (missing '@')")
                continue
            asn_str, ip = entry.split('@', 1)
            try:
                remote_as = int(asn_str)
            except ValueError:
                LOG.warning(f"⚠ Ignoring invalid ASN '{asn_str}' in bgp_peer_list entry '{entry}'")
                continue

            self.speaker.neighbor_add(
                address=ip,
                remote_as=remote_as,
                enable_ipv6=True,
                connect_mode='active',
                local_address=self.conf.bgp_our_router_id,
            )
            self.peers.append((ip, remote_as))
            LOG.info(f"⚙ Peer added : {ip} (AS: {remote_as})")

        # Initialize the stop event for reconnect thread
        self._stop_reconnect = threading.Event()

        # Start the background reconnect thread
        self._reconnect_thread = threading.Thread(target=self._reconnect_loop, daemon=True)
        self._reconnect_thread.start()

        # Session startup delay
        time.sleep(5)

    def _send_route_to_all_peers(self, prefix, next_hop, neighbor=None):
        try:
            if neighbor:
                self.speaker.prefix_add(prefix=prefix, next_hop=next_hop, neighbor=neighbor)
            else:
                self.speaker.prefix_add(prefix=prefix, next_hop=next_hop)
        except Exception as e:
            target = neighbor if neighbor else "all peers"
            LOG.warning(f"⚠ Failed to inject {prefix} via {next_hop} to {target}: {e}")

    def _reconnect_loop(self):
        while not self._stop_reconnect.is_set():
            for ip, remote_as in self.peers:
                try:
                    neighbor_info = self.speaker.neighbor_state_get(address=ip, format='json')
                    if isinstance(neighbor_info, dict):
                        state = neighbor_info.get('bgp_state')
                    else:
                        state = str(neighbor_info)
                    if state in ('Idle', 'Connect', None):
                        LOG.warning(f"⚠ Neighbor {ip} is {state}, attempting reconnect")
                        self.speaker.neighbor_del(address=ip)
                        time.sleep(1)  # small pause
                        self.speaker.neighbor_add(
                            address=ip,
                            remote_as=remote_as,
                            enable_ipv6=True,
                            connect_mode='active',
                            local_address=self.conf.bgp_our_router_id,
                        )
                        LOG.info(f"⚙ Re-added neighbor {ip}")
                        # Re-inject all routes for this neighbor
                        for prefix, next_hop in self.injected_routes.items():
                            self._send_route_to_all_peers(prefix, next_hop, neighbor=ip)
                            LOG.info(f"⚙ Re-injected {prefix} via {next_hop} to {ip}")
                except Exception as e:
                    LOG.warning(f"⚠ Error checking/reconnecting neighbor {ip}: {e}")
            time.sleep(self.conf.bgp_peer_reconnect_interval)

#    def list_neighbors(self, format='json'):
#        """List all BGP neighbors"""
#        all_neighbors = {}
#        for ip in self.peers:
#            try:
#                neighbor_info = self.speaker.neighbor_state_get(address=ip, format=format)
#                all_neighbors[ip] = neighbor_info
#            except Exception as e:
#                LOG.warning(f"⚠ Failed to get neighbor state for {ip}: {e}")
#        return all_neighbors
#
    def inject_route(self, prefix, next_hop):
        """Inject a BGP route"""
        route = (prefix, next_hop)
        if prefix in self.injected_routes:
            LOG.warning(f"⚠ Route already injected: {prefix} via {next_hop}")
            return

        self.injected_routes[prefix] = next_hop
        self._send_route_to_all_peers(prefix, next_hop)
        LOG.info(f"✔ Route injected: {prefix}")

#    def list_routes(self):
#        """Show injected routes"""
#        if not self.injected_routes:
#            LOG.info("No route injected.")
#            return []
#
#        LOG.info("📋 BGP routes injected:")
#        for prefix, next_hop in self.injected_routes.items():
#            LOG.info(f"  - {prefix} via {next_hop}")
#        return list(self.injected_routes.items())
#
    def withdraw_route(self, prefix):
        """Withdraw a BGP route from all peers"""
        if prefix not in self.injected_routes:
            LOG.warning(f"⚠ Route not found (not injected locally) : {prefix}")
            return

        next_hop = self.injected_routes[prefix]
        LOG.debug(f"🐛 Withdrawing route {prefix} via {next_hop} from all peers")
        self.speaker.prefix_del(prefix=prefix)
        del self.injected_routes[prefix]
        LOG.info(f"𐄂 Route removed: {prefix}")

    def shutdown(self):
        """Stop background reconnect thread and shut down BGP speaker"""
        self._stop_reconnect.set()
        if self._reconnect_thread.is_alive():
            # join() by itself does not stop the thread. It only blocks the calling
            # thread (the main thread) until the target thread finishes.
            self._reconnect_thread.join()
        """BGP speaker shutdown"""
        LOG.info("⚙ BGP Speaker shutdown...")
        self.speaker.shutdown()

# ----------------------------------------------------
# Get notified on new IPv6 from RabbitMQ notifications
# ----------------------------------------------------
class PortEventEndpoint(object):
    """Handler for port events from Neutron."""

    def __init__(self, conf, bgp_manager, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict):
        self.conf = conf
        self.bgp_manager = bgp_manager
        self.subnet_id_to_nexthop_dict = subnet_id_to_nexthop_dict
        self.port_id_to_ipv6_dict = port_id_to_ipv6_dict

    def info(self, ctxt, publisher_id, event_type, payload, metadata):
#        print(f"[DEBUG] Got event {event_type} from {publisher_id}")
#        print(f"[DEBUG] Payload: {payload}")
        LOG.debug("🐛 Got event %s from %s", event_type, publisher_id)

        # Only care about port create/update events
        if event_type not in ("port.create.end", "port.update.end", "port.delete.end"):
            return

        port = payload.get("port")
        if not port:
            return

        # Filter: only IPv6 + subnets of interest
        configured = set(self.conf.subnet_list)

        port_id = port.get("id")

        # Are we in a case of port update removal of the IPv6 ?
        found_addr = False
        subnet_id = None
        if port_id in self.port_id_to_ipv6_dict and event_type == "port.update.end":
            for ip in port.get("fixed_ips", []):
                address = ip.get("ip_address")
                if address == self.port_id_to_ipv6_dict[port_id]:
                    # If we're seeing an IPv6, it means that either it's an update of the
                    # port for something else, or we're adding the IPv6 to the port.
                    # The later is handled below.
                    found_addr = True
            if found_addr == False:
                # If we can't find our IPv6 *anymore* for this port, it means
                # the update of the port is a removal of the IPv6.
                address = self.port_id_to_ipv6_dict[port_id]
                LOG.info(f"🐰 Received Neutron notification of port update: removal of IPv6 for Port: {port_id}, IPv6 addresses: {address}")
                self.bgp_manager.withdraw_route(address + '/128')
                del self.port_id_to_ipv6_dict[port_id]
                return
        
        for ip in port.get("fixed_ips", []):
            subnet_id = ip.get("subnet_id")
            address = ip.get("ip_address")

            # You’d need to map subnet_id -> name to match against your list
            # (can cache this with conn.network.subnets() at startup)
            if address and ipaddress.ip_address(address).version == 6:
                if event_type == "port.create.end":
                    if subnet_id in subnet_id_to_nexthop_dict:
                        LOG.info(f"🐰 Received Neutron notification of port create: IPv6 address reserved: Port: {port_id}, IPv6 addresses: {address}, Subnet: {subnet_id}")
                        self.bgp_manager.inject_route(address + '/128', self.subnet_id_to_nexthop_dict[subnet_id])
                        self.port_id_to_ipv6_dict[port_id] = address
                if event_type == "port.update.end":
                    if port_id not in self.port_id_to_ipv6_dict:
                        # In this case, an IPv6 is being added to the port.
                        LOG.info(f"🐰 Received Neutron notification of port update: addition of IPv6 for Port: Port: {port_id}, IPv6 addresses: {address}, Subnet: {subnet_id}")
                        self.bgp_manager.inject_route(address + '/128', self.subnet_id_to_nexthop_dict[subnet_id])
                        self.port_id_to_ipv6_dict[port_id] = address
                if event_type == "port.delete.end":
                    LOG.info(f"🐰 Received Neutron notification of port delete: Port: {port_id}, IPv6 addresses: {address}, Subnet: {subnet_id}")
                    self.bgp_manager.withdraw_route(address + '/128')
                    del self.port_id_to_ipv6_dict[port_id]


def run_inject0r():
    #####################################################################
    ### /etc/neutron-ipv6-bgp-injector/neutron-ipv6-bgp-injector.conf ###
    #####################################################################
    # Create the conf object.
    conf = cfg.ConfigOpts()

    # Register "normal" OPTs from common_opts.
    conf.register_opts(common_opts)

    # Load oslo messaging opts.
    for group, opts in messaging_opts.list_opts():
        conf.register_opts(opts, group=group)
#    oslo_messaging.set_transport_defaults(conf)

    # Register the [neutron] section stuff.
    ks_loading.register_session_conf_options(conf, "neutron")
    ks_loading.register_auth_conf_options(conf, "neutron")
    ks_loading.register_adapter_conf_options(conf, "neutron")

    # Register logs opts.
    logging.register_options(conf)
 
    # Do the actual loading of the configuration
    conf(args=sys.argv[1:], project='neutron-ipv6-bgp-injector')
    logging.setup(conf, conf.project)

    LOG.info(f"⚙ Neutron IPv6 BGP Injector is starting...")

    # Silence the BGPSpeaker logging a little bit.
    if not conf.debug:
        import logging as pylogging
        pylogging.getLogger("bgpspeaker.api.base").setLevel(pylogging.WARNING)

    ##################################################
    ### Get a list of all IPs of subnets listed in ###
    ##################################################
    # neutron-ipv6-bgp-injector.conf [DEFAULT]/subnet_list
    LOG.info("⚙ Fetching all IPv6 next HOP of configured subnets...")
    ipv6_to_next_hop_dict, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict = query_neutron_ipv6_next_hop(conf)

    #####################################
    ### BGP advertizer initialization ###
    #####################################
    LOG.info("⚙ Preparing BGP advertizer...")
    bgp_manager = BGPSpeakerManager(conf=conf)

    for ip6, next_hop in ipv6_to_next_hop_dict.items():
        bgp_manager.inject_route(prefix = ip6 + '/128', next_hop=next_hop)

    ######################################################
    ### RabbitMQ notifications for new / deleted ports ###
    ######################################################
    LOG.info("⚙ Preparing RabbitMQ notification listener...")
    # Get transport from the config (RabbitMQ connection, vhost, credentials)
    transport = oslo_messaging.get_transport(conf, url=conf.oslo_messaging_notifications.transport_url)
    targets = [
        oslo_messaging.Target(topic=conf.neutron_notifications_topic, exchange=conf.neutron_notifications_exchange_name, fanout=True)
    ]

    # Endpoint instance
    endpoints = [PortEventEndpoint(conf, bgp_manager, subnet_id_to_nexthop_dict, port_id_to_ipv6_dict)]

    # Create the notification listener
    server = oslo_messaging.get_notification_listener(
        transport,
        targets,
        endpoints,
        executor='eventlet',  # or 'eventlet' if you prefer
    )

    # --- Signal handler ---
    def shutdown_handler(signum, frame):
        LOG.info(f"⚙ Received signal {signum}, shutting down gracefully...")

        def shutdown_task():
            try:
                LOG.info(f"⚙ Stopping RabbitMQ listen...")
                server.stop()  # stop the notification listener
            except Exception as e:
                LOG.error(f"⧱ Error stopping server: {e}")
            try:
                LOG.info(f"⚙ Stopping BGP advertizer...")
                bgp_manager.shutdown()  # shutdown BGP speaker
            except Exception as e:
                LOG.error(f"⧱ Error shutting down BGP speaker: {e}")
            LOG.info(f"⚙ Finished shutting down: bye bye!")
            sys.exit(0)

        eventlet.spawn(shutdown_task)

    # Register signal handler
    signal.signal(signal.SIGTERM, shutdown_handler)
    signal.signal(signal.SIGINT, shutdown_handler)

    LOG.info("⚙ Starting port event listener...")
    # Notify systemd the daemon is ready.
    sd_notify("READY=1")
    server.start()
    LOG.info("⚙ Startup finished: ready for operations.")
    server.wait()  # blocks, listening for messages

    # If wait returns, also shutdown gracefully
    shutdown_handler(signal.SIGTERM, None)

if __name__ == "__main__":
    run_inject0r()
    exit(0)
