diff --git a/docs/ai/ibgp-design-revision.md b/docs/ai/ibgp-design-revision.md new file mode 100644 index 000000000..e54c42a69 --- /dev/null +++ b/docs/ai/ibgp-design-revision.md @@ -0,0 +1,28 @@ +# Design: IBGP with Autonomous System + +## Goal + +Revise the current design of the ibgp layer and the `AutonomousSystem` class + + +## Design Principles + +- Clearly separate the duties of `AutonomousSystem` class (AS) and the ibgp layers. Deciding the ibgp mode withing the AS and roles of routers should be done in the AS, not in the ibgp layer. +- Although IBGP might have many modes, we would like to stick to this principle: simple default model, clean extension points, minimal built-in special cases. +- Avoid adding special cases to core layers unless they represent major research or operational patterns. +- Every new API should have a small example and a test. + + +## Required Behavior + + +- Inside the Autonomous System class, set the following (use the first option as the default) +``` +ibgp_mode = "full-mesh" | "route-reflector" +bgp_scope = "all-routers" | "edge-only" +core_forwarding = "plain-ip" | "mpls" | "sr" | "tunnel" | "redistribute" +``` +- all-routers + full-mesh: All routers participate in ibgp using full-mesh peering +- all-routers + route-reflector: All routers participate in ibgp using route reflectors +- edge-only + full-mesh: Only edge routers participate in ibgp using full-mesh peerin; core routers do not participate in ibgp +- edge-only + route-reflector: Only edge routers participate in ibgp using route reflector; core routers do not participate in ibgp diff --git a/docs/ai/route-reflector-code-details.md b/docs/ai/route-reflector-code-details.md new file mode 100644 index 000000000..d4546c238 --- /dev/null +++ b/docs/ai/route-reflector-code-details.md @@ -0,0 +1,44 @@ +# AS-level iBGP Mode Prompt + +## Background + +Original related code locations: + +- `seedemu/core/AutonomousSystem.py` + - `createBgpCluster(address)` registers an RR cluster ID. + - `_aggregateBgpClusters()` aggregates cluster, RR, and client membership. + - The current implicit default cluster ID is hard-coded as `"10.0.0.0"`. +- `seedemu/core/Node.py` + - `Router.makeRouteReflector()` records whether a router is an RR. + - `Router.joinBgpCluster(cluster_id)` records the cluster a router belongs to. +- `seedemu/layers/Ibgp.py` + - `configure()` currently calls `asobj._aggregateBgpClusters()`. + - It currently uses `has_rr = any(len(rrs) > 0 ...)` to decide whether to use RR mode or full mesh mode. + - `_render_rr_mode()` writes RR/client session intent into routers. + - `_render_full_mesh_mode()` keeps the legacy full mesh behavior. +- `seedemu/layers/_bgp_metadata.py` + - `route_reflector_client` and `route_reflector_cluster_id` are BGP intent fields. + - BIRD renders `rr client` and `rr cluster id ...`. +- `seedemu/layers/Routing.py` + - FRR renders `bgp cluster-id ...` and `neighbor ... route-reflector-client`. + +## Behavior Requirements + +1. `Ibgp.configure()` must no longer infer the mode by itself using `has_rr`. +2. `Ibgp.configure()` should get the effective iBGP mode from `AutonomousSystem`: + - `"full-mesh"` calls `_render_full_mesh_mode()`. + - `"route-reflector"` calls `_render_rr_mode()`. +3. RR cluster aggregation, the default cluster ID, and default RR selection must all be completed inside `AutonomousSystem`. +4. For an AS to enter effective `"route-reflector"` mode, the user must explicitly call `setIbgpMode("route-reflector")`. Only after that should the following APIs be callable: + - `createBgpCluster()`. + - `makeRouteReflector(True)`. + - `joinBgpCluster(cluster_id)`. +5. If the user explicitly sets `"full-mesh"`, that setting should be respected unless RR-specific configuration already exists. + If there is a conflict, do not silently ignore it. Raise a clear error, for example: + `"AS2 has route-reflector cluster/router configuration but ibgp_mode is full-mesh"`. +6. Multi-cluster RR must still validate that every cluster has an RR and clients. The default RR is only used when the user selected `"route-reflector"` and did not provide any RR configuration. +7. Do not change the intent schema in `_bgp_metadata.py`. Continue using the existing fields + `route_reflector_client` and `route_reflector_cluster_id`. +8. Do not change BIRD/FRR rendering semantics. BIRD should still render RR configuration through `_bgp_metadata.py`, and FRR should still render cluster-id and route-reflector-client through `Routing.py`. +9. Existing old RR usage through `createBgpCluster()`, `joinBgpCluster()`, and `makeRouteReflector()` must not be broken. +10. `setIbgpMode()` must validate the input. Invalid values should directly raise `ValueError` or `AssertionError`, and the error message must include the valid values. diff --git a/docs/ai/route-reflector-design.md b/docs/ai/route-reflector-design.md new file mode 100644 index 000000000..248e1dd00 --- /dev/null +++ b/docs/ai/route-reflector-design.md @@ -0,0 +1,31 @@ +# Design: Route Reflector + +## Goal + +Revise the implemenation of the route reflector in the emulator + + +## Design Principles + +- Keep the configuration of route reflector structure inside the `AutonomousSystem` class, not in the ibgp layer. + + +## API design + +- Introduce an API called `completeIbgpSetup()` inside the `AutonomousSystem` class. It inspects the `AutonomousSystem` object and all the routers inside this AS, finding any place where ibgp setup is incomplete, and complete it. +- The reason for this API: Users might not set up the route reflector structure inside the AS completely, we need to automatically complete the setup. +- Who invokes this API? When `ibgp` starts rendering for an AS, it first calls this API to complete the ibgp setup within the AS. +- Principle: We choose to do this within the AS class, not at the ibgp layer, because we want all the ibgp-related setup to be done inside the AS, and the ibgp layer's job only focuses on rendering, i.e., turning the setup into actual system setup. + + + +## Required Behavior + +- Ensure at least one cluster ID is created for the AS. If users do not set one, a deterministic default cluster ID must be created. The first created cluster ID is set as the default. +- Each cluster must have at least one route reflector, if users do not set one, a determininistic router is selected as the RR. If the user does not specify a route reflector for the default single-cluster setup, the emulator deterministically selects one router as the default route reflector. The default rule is: Select the first router in this AS after sorting router names in ascending order.(A cluster may contain multiple route reflectors. Among them, one route reflector is treated as the default route reflector of that cluster.) +- Each router must join one cluster ID; if users do not set one, the router joins the default one of the AS. +- Each router must have one RR; if users do not set one, the default RR in the cluster is used. +- A cluster allows multiple route reflectors, with one being the default. All the route reflectors within the same cluster must peer with one another. +- The default cluster ID must be decided by the AS. Use an ASN-derived IPv4-style string, for example `"10.{asn}.0.1"`, and ensure it does not conflict. +- If the user configures multiple cluster IDs within the same AS, the emulator assumes that the user intends to define a more advanced route reflector topology. In this case, the emulator should not create an additional default cluster ID or automatically select a default route reflector. Instead, the user must explicitly provide all required route reflector information:Every router must explicitly join one cluster ID. Every configured cluster ID must have at least one route reflector. Each router’s cluster ID must exist. Each router must be associated with a valid route reflector in its cluster, unless the router itself is a route reflector. All route reflectors inside the same cluster must peer with one another. If any required information is missing, completeIbgpSetup() should raise a clear error. Examples of error cases: Router r3 in AS 150 is missing a cluster ID; Cluster 10.150.0.2 in AS 150 has no route reflector. + diff --git a/examples/basic/A62_route_reflector/README.md b/examples/basic/A62_route_reflector/README.md index d228f9b56..22d994034 100644 --- a/examples/basic/A62_route_reflector/README.md +++ b/examples/basic/A62_route_reflector/README.md @@ -114,6 +114,7 @@ marking the RR router: ```python as12 = base.getAutonomousSystem(12) +as12.setIbgpMode("route-reflector") as12.createBgpCluster("10.12.0.1") as12.getRouter("r101").joinBgpCluster("10.12.0.1").makeRouteReflector() as12.getRouter("r104").joinBgpCluster("10.12.0.1") diff --git a/examples/basic/A62_route_reflector/route_reflector.py b/examples/basic/A62_route_reflector/route_reflector.py index 83d656745..0ea87c310 100644 --- a/examples/basic/A62_route_reflector/route_reflector.py +++ b/examples/basic/A62_route_reflector/route_reflector.py @@ -126,12 +126,14 @@ def build_mini_internet(emu: Emulator, base: Base, ebgp: Ebgp, hosts_per_as: int # AS12 demonstrates a single Route Reflector and one client. as12 = base.getAutonomousSystem(12) + as12.setIbgpMode("route-reflector") as12.createBgpCluster(AS12_CLUSTER_ID) as12.getRouter("r101").joinBgpCluster(AS12_CLUSTER_ID).makeRouteReflector() as12.getRouter("r104").joinBgpCluster(AS12_CLUSTER_ID) # AS3 demonstrates two RR clusters plus an RR-to-RR mesh. as3 = base.getAutonomousSystem(3) + as3.setIbgpMode("route-reflector") as3.createBgpCluster(AS3_WEST_CLUSTER_ID) as3.createBgpCluster(AS3_EAST_CLUSTER_ID) as3.getRouter("r100").joinBgpCluster(AS3_WEST_CLUSTER_ID).makeRouteReflector() diff --git a/examples/basic/A63_control_plane_regression/control_plane_regression.py b/examples/basic/A63_control_plane_regression/control_plane_regression.py index 9daac9483..91ac5a695 100644 --- a/examples/basic/A63_control_plane_regression/control_plane_regression.py +++ b/examples/basic/A63_control_plane_regression/control_plane_regression.py @@ -58,6 +58,24 @@ def build_route_server_slice(emu: Emulator, base: Base, ebgp: Ebgp, web: WebServ ebgp.addRsPeer(100, asn) +def build_frr_route_server_slice(emu: Emulator, base: Base, ebgp: Ebgp, web: WebService) -> None: + """Exercise a FRR route-server IX without changing the legacy BIRD RS default.""" + + ix = base.createInternetExchange(107) + ix.getRouteServerNode().setRoutingBackend("frr") + + for asn in [157, 158]: + current_as = base.createAutonomousSystem(asn) + current_as.createNetwork("net0") + current_as.createRouter("router0").joinNetwork("net0").joinNetwork("ix107") + current_as.createHost("web").joinNetwork("net0") + + vnode = "web{}".format(asn) + web.install(vnode) + emu.addBinding(Binding(vnode, filter=Filter(nodeName="web", asn=asn))) + ebgp.addRsPeer(107, asn) + + def build_mixed_backend_slice(emu: Emulator, base: Base, ebgp: Ebgp, web: WebService) -> None: """Exercise one transit AS with BIRD and FRR routers from shared BGP/OSPF intent.""" @@ -94,6 +112,7 @@ def build_frr_route_reflector_slice(base: Base, ebgp: Ebgp) -> None: as3 = base.createAutonomousSystem(3) as3.createNetwork("net0") + as3.setIbgpMode("route-reflector") as3.createBgpCluster(AS3_CLUSTER_ID) as3.createRouter("rr", routingBackend="frr").joinNetwork("net0").joinNetwork("ix103").joinBgpCluster(AS3_CLUSTER_ID).makeRouteReflector() as3.createRouter("client", routingBackend="frr").joinNetwork("net0").joinBgpCluster(AS3_CLUSTER_ID) @@ -166,6 +185,7 @@ def build_emulator() -> Emulator: web = WebService() build_route_server_slice(emu, base, ebgp, web) + build_frr_route_server_slice(emu, base, ebgp, web) build_mixed_backend_slice(emu, base, ebgp, web) build_frr_route_reflector_slice(base, ebgp) build_exabgp_slice(base, exabgp, emu) diff --git a/examples/basic/A63_control_plane_regression/test_runtime.py b/examples/basic/A63_control_plane_regression/test_runtime.py index 4745889d5..e5907f547 100644 --- a/examples/basic/A63_control_plane_regression/test_runtime.py +++ b/examples/basic/A63_control_plane_regression/test_runtime.py @@ -45,6 +45,9 @@ def main() -> int: rs100 = test.require_service(100, "ix100", "IX100 BIRD route server is generated") as150_router = test.require_service(150, "router0") as151_router = test.require_service(151, "router0") + rs107 = test.require_service(107, "ix107", "IX107 FRR route server is generated") + as157_router = test.require_service(157, "router0") + as158_router = test.require_service(158, "router0") as2_r1 = test.require_service(2, "r1") as2_r2 = test.require_service(2, "r2") as152_router = test.require_service(152, "router0") @@ -82,6 +85,24 @@ def main() -> int: if as151_router: test.exec_check("AS151 learns AS150 route through route server", as151_router, "birdc show route | grep -q '10.150.0.0/24'", retries=15) + if rs107: + require_backend(test, rs107, "frr") + test.exec_check("IX107 route server starts FRR bgpd", rs107, "pgrep -x bgpd >/dev/null") + test.exec_check("IX107 route server does not start BIRD", rs107, "! pgrep -x bird >/dev/null") + test.exec_check("IX107 route server renders AS157 as RS client", rs107, "grep -q 'neighbor 10.107.0.157 route-server-client' /etc/frr/frr.conf") + test.exec_check("IX107 route server renders AS158 as RS client", rs107, "grep -q 'neighbor 10.107.0.158 route-server-client' /etc/frr/frr.conf") + test.exec_check("IX107 route server sees AS157 BGP session", rs107, "vtysh -c 'show ip bgp summary' | grep -q '10.107.0.157'", retries=15) + test.exec_check("IX107 route server sees AS158 BGP session", rs107, "vtysh -c 'show ip bgp summary' | grep -q '10.107.0.158'", retries=15) + + if as157_router: + require_backend(test, as157_router, "bird") + test.exec_check("AS157 route-server session is established", as157_router, "birdc show protocols | grep -q 'p_rs107.*Established'", retries=15) + + if as158_router: + require_backend(test, as158_router, "bird") + test.exec_check("AS158 route-server session is established", as158_router, "birdc show protocols | grep -q 'p_rs107.*Established'", retries=15) + test.exec_check("AS158 learns AS157 route through FRR route server", as158_router, "birdc show route | grep -q '10.157.0.0/24'", retries=15) + if as2_r1: require_backend(test, as2_r1, "bird") test.exec_check("AS2 r1 starts BIRD", as2_r1, "pgrep -x bird >/dev/null") diff --git a/seedemu/core/AutonomousSystem.py b/seedemu/core/AutonomousSystem.py index 25e57851c..62563b087 100644 --- a/seedemu/core/AutonomousSystem.py +++ b/seedemu/core/AutonomousSystem.py @@ -4,18 +4,49 @@ from .Network import Network from .AddressAssignmentConstraint import AddressAssignmentConstraint from .enums import NetworkType, NodeRole -from .Node import Node, Router +from .Node import Node, Router, ROUTER_BGP_ROLE_EDGE from .Scope import ScopeTier, Scope from .Emulator import Emulator from .Configurable import Configurable from .Customizable import Customizable from .Node import promote_to_real_world_router from ipaddress import IPv4Network -from typing import Dict, List, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple import requests RIS_PREFIXLIST_URL = 'https://stat.ripe.net/data/announced-prefixes/data.json' +IBGP_MODE_FULL_MESH = "full-mesh" +IBGP_MODE_ROUTE_REFLECTOR = "route-reflector" +IBGP_MODE_DISABLED = "disabled" + +IBGP_MODES = { + IBGP_MODE_FULL_MESH, + IBGP_MODE_ROUTE_REFLECTOR, + IBGP_MODE_DISABLED, +} + +BGP_SCOPE_ALL_ROUTERS = "all-routers" +BGP_SCOPE_EDGE_ONLY = "edge-only" +BGP_SCOPES = {BGP_SCOPE_ALL_ROUTERS, BGP_SCOPE_EDGE_ONLY} + +CORE_FORWARDING_PLAIN_IP = "plain-ip" +CORE_FORWARDING_MPLS = "mpls" +CORE_FORWARDING_SR = "sr" +CORE_FORWARDING_TUNNEL = "tunnel" +CORE_FORWARDING_REDISTRIBUTE = "redistribute" +CORE_FORWARDING_MODES = { + CORE_FORWARDING_PLAIN_IP, + CORE_FORWARDING_MPLS, + CORE_FORWARDING_SR, + CORE_FORWARDING_TUNNEL, + CORE_FORWARDING_REDISTRIBUTE, +} + +OSPF_MODE_LEGACY = "legacy" +OSPF_MODE_ROUTER_TRANSIT_ONLY = "router-transit-only" +OSPF_MODES = {OSPF_MODE_LEGACY, OSPF_MODE_ROUTER_TRANSIT_ONLY} + class AutonomousSystem(Printable, Graphable, Configurable, Customizable): """! @brief AutonomousSystem class. @@ -30,6 +61,12 @@ class AutonomousSystem(Printable, Graphable, Configurable, Customizable): __nets: Dict[str, Network] __name_servers: List[str] __clusters: Dict[str, Tuple[Set[str], Set[str]]] + __ibgp_mode: str + __ibgp_mode_explicit: bool + __bgp_scope: str + __core_forwarding: str + __ospf_mode: str + __ospf_mode_explicit: bool def __init__(self, asn: int, subnetTemplate: str = "10.{}.0.0/16"): """! @@ -46,6 +83,132 @@ def __init__(self, asn: int, subnetTemplate: str = "10.{}.0.0/16"): self.__subnets = None if asn > 255 else list(IPv4Network(subnetTemplate.format(asn)).subnets(new_prefix = 24)) self.__name_servers = [] self.__clusters = {} + self.__ibgp_mode = IBGP_MODE_FULL_MESH + self.__ibgp_mode_explicit = False + self.__bgp_scope = BGP_SCOPE_ALL_ROUTERS + self.__core_forwarding = CORE_FORWARDING_PLAIN_IP + self.__ospf_mode = OSPF_MODE_LEGACY + self.__ospf_mode_explicit = False + + def setIbgpMode(self, mode: str) -> AutonomousSystem: + """! + @brief Set the AS-level iBGP route propagation mode. + + The default is full-mesh when unset. This setting records AS + intent; the Ibgp layer still renders concrete BGP sessions. + + @param mode full-mesh, route-reflector, or disabled. + + @returns self, for chaining API calls. + """ + value = self._normalizeIbgpMode(mode) + assert value in IBGP_MODES, "unsupported iBGP mode: {}. valid values: {}".format( + mode, sorted(IBGP_MODES) + ) + self.__ibgp_mode = value + self.__ibgp_mode_explicit = True + return self + + def _normalizeIbgpMode(self, mode: str) -> str: + value = str(mode or IBGP_MODE_FULL_MESH).strip().lower() + return value + + def getIbgpMode(self) -> str: + """! + @brief Get the AS-level iBGP route propagation mode. + """ + return self.__ibgp_mode + + def hasIbgpMode(self) -> bool: + """! + @brief Return whether an iBGP mode was explicitly set on this AS. + """ + return self.__ibgp_mode_explicit + + def setBgpScope(self, scope: str) -> AutonomousSystem: + """! + @brief Set which routers participate in BGP/iBGP inside this AS. + + @param scope all-routers or edge-only. + + @returns self, for chaining API calls. + """ + value = str(scope or BGP_SCOPE_ALL_ROUTERS).strip().lower() + assert value in BGP_SCOPES, "unsupported BGP scope: {}. valid values: {}".format( + scope, sorted(BGP_SCOPES) + ) + self.__bgp_scope = value + return self + + def getBgpScope(self) -> str: + """! + @brief Get the AS-level BGP participation scope. + """ + return self.__bgp_scope + + def setCoreForwarding(self, mode: str) -> AutonomousSystem: + """! + @brief Set the forwarding mechanism expected for a BGP-free core. + + @param mode plain-ip, mpls, sr, tunnel, or redistribute. + + @returns self, for chaining API calls. + """ + value = str(mode or CORE_FORWARDING_PLAIN_IP).strip().lower() + assert value in CORE_FORWARDING_MODES, "unsupported core forwarding mode: {}. valid values: {}".format( + mode, sorted(CORE_FORWARDING_MODES) + ) + self.__core_forwarding = value + return self + + def getCoreForwarding(self) -> str: + """! + @brief Get the AS-level core forwarding mode. + """ + return self.__core_forwarding + + def setIbgpDesign( + self, + mode: str = IBGP_MODE_FULL_MESH, + scope: str = BGP_SCOPE_ALL_ROUTERS, + core_forwarding: str = CORE_FORWARDING_PLAIN_IP, + ) -> AutonomousSystem: + """! + @brief Set iBGP mode, BGP scope, and core forwarding together. + """ + self.setIbgpMode(mode) + self.setBgpScope(scope) + self.setCoreForwarding(core_forwarding) + return self + + def setOspfMode(self, mode: str) -> AutonomousSystem: + """! + @brief Set the AS-level OSPF interface classification mode. + + The default is legacy when unset. Ospf records interface intent and + Routing renders backend-specific BIRD/FRR configuration. + + @param mode legacy or router-transit-only. + + @returns self, for chaining API calls. + """ + value = str(mode or OSPF_MODE_LEGACY).strip().lower() + assert value in OSPF_MODES, "unsupported OSPF mode: {}".format(mode) + self.__ospf_mode = value + self.__ospf_mode_explicit = True + return self + + def getOspfMode(self) -> str: + """! + @brief Get the AS-level OSPF interface classification mode. + """ + return self.__ospf_mode + + def hasOspfMode(self) -> bool: + """! + @brief Return whether an OSPF mode was explicitly set on this AS. + """ + return self.__ospf_mode_explicit def createBgpCluster(self, address: str) -> AutonomousSystem: """! @@ -58,31 +221,79 @@ def createBgpCluster(self, address: str) -> AutonomousSystem: @returns self, for chaining API calls. """ + self.__ensureRouteReflectorModeAllowed("createBgpCluster") + if not self.__ibgp_mode_explicit: + self.__ibgp_mode = IBGP_MODE_ROUTE_REFLECTOR + self.__ibgp_mode_explicit = True if address not in self.__clusters: self.__clusters[address] = (set(), set()) return self + def __ensureRouteReflectorModeAllowed(self, api_name: str) -> None: + if self.__ibgp_mode_explicit and self.__ibgp_mode != IBGP_MODE_ROUTE_REFLECTOR: + raise AssertionError( + "AS{} cannot call {} while ibgp_mode is {}".format( + self.__asn, api_name, self.__ibgp_mode + ) + ) + + def __hasRouteReflectorRouterConfig(self) -> bool: + for router in self.__routers.values(): + if router.getBgpClusterId() is not None or router.isRouteReflector(): + return True + return False + + def __hasRouteReflectorConfig(self) -> bool: + return len(self.__clusters) > 0 or self.__hasRouteReflectorRouterConfig() + + def __defaultBgpClusterId(self) -> str: + if self.__asn <= 255: + base = "10.{}.0".format(self.__asn) + else: + base = "10.{}.{}".format((self.__asn // 256) % 256, self.__asn % 256) + + for suffix in range(1, 255): + candidate = "{}.{}".format(base, suffix) + if candidate not in self.__clusters: + return candidate + + raise AssertionError("AS{} cannot allocate a default BGP cluster ID".format(self.__asn)) + + def __getIbgpParticipantRouters(self) -> List[Router]: + routers = list(self.__routers.values()) + if self.__bgp_scope == BGP_SCOPE_ALL_ROUTERS: + return routers + + participants = [ + router for router in routers + if ( + (hasattr(router, "getBgpRole") and router.getBgpRole() == ROUTER_BGP_ROLE_EDGE) + or router.isBorderRouter() + ) + ] + assert len(participants) > 0, ( + "AS{} has bgp_scope=edge-only but no edge routers were found; " + "mark routers with setBgpRole('edge') or connect them to IXes".format(self.__asn) + ) + return participants + + def getIbgpParticipants(self) -> Set[str]: + """! + @brief Get router names participating in the AS iBGP design. + """ + return {router.getName() for router in self.__getIbgpParticipantRouters()} + def _validate_cluster_integrity(self, data: Dict[str, Tuple[Set[str], Set[str]]]): """! @brief Validate Route Reflector cluster membership. - A single cluster without any RR is treated as the legacy full-mesh iBGP - topology. Multi-cluster topologies, or any topology containing an RR, - must satisfy the RR/client contract. - @param data mapping from cluster ID to RR names and client names. """ - if len(data) == 1: - _, (rrs, _) = list(data.items())[0] - if len(rrs) == 0: - return - for cid, (rr_set, client_set) in data.items(): assert len(rr_set) > 0, ( "[Topology Error] AS{} cluster '{}' is invalid: missing Route " - "Reflector. In a multi-cluster or RR topology, every cluster " - "must have an RR.".format(self.__asn, cid) + "Reflector.".format(self.__asn, cid) ) assert len(client_set) > 0, ( "[Topology Error] AS{} cluster '{}' is invalid: missing clients. " @@ -91,7 +302,92 @@ def _validate_cluster_integrity(self, data: Dict[str, Tuple[Set[str], Set[str]]] ) ) - def _aggregateBgpClusters(self) -> Dict[str, Tuple[Set[str], Set[str]]]: + def completeIbgpSetup(self) -> AutonomousSystem: + """! + @brief Complete and validate the AS-level iBGP design. + + The AS owns iBGP intent. The Ibgp layer only renders the completed + design into router configuration. + """ + mode = self.getIbgpMode() + has_rr_config = self.__hasRouteReflectorConfig() + + if mode == IBGP_MODE_FULL_MESH: + if self.hasIbgpMode() and has_rr_config: + raise AssertionError( + "AS{} has route-reflector cluster/router configuration but ibgp_mode is full-mesh".format( + self.__asn + ) + ) + if not self.hasIbgpMode() and has_rr_config: + self.__ibgp_mode = IBGP_MODE_ROUTE_REFLECTOR + mode = IBGP_MODE_ROUTE_REFLECTOR + + if mode == IBGP_MODE_DISABLED: + return self + + participants = self.__getIbgpParticipantRouters() + if mode == IBGP_MODE_FULL_MESH: + return self + + assert mode == IBGP_MODE_ROUTE_REFLECTOR, "unsupported iBGP mode: {}".format(mode) + + explicit_clusters = len(self.__clusters) > 0 + advanced_multi_cluster = len(self.__clusters) > 1 + + if not explicit_clusters: + self.createBgpCluster(self.__defaultBgpClusterId()) + + if advanced_multi_cluster: + self.__completeAdvancedRouteReflectorSetup(participants) + else: + self.__completeDefaultRouteReflectorSetup(participants) + + return self + + def __completeDefaultRouteReflectorSetup(self, participants: List[Router]) -> None: + assert len(participants) > 0, "AS{} route-reflector mode requires at least one router".format(self.__asn) + cluster_id = sorted(self.__clusters.keys())[0] + + for router in participants: + if router.getBgpClusterId() is None: + router.joinBgpCluster(cluster_id) + assert router.getBgpClusterId() == cluster_id, ( + "AS{} router {} joined unknown or non-default cluster {}".format( + self.__asn, router.getName(), router.getBgpClusterId() + ) + ) + + rrs = [router for router in participants if router.isRouteReflector()] + if not rrs: + sorted(participants, key=lambda router: router.getName())[0].makeRouteReflector() + + self._aggregateBgpClusters(validate=True) + + def __completeAdvancedRouteReflectorSetup(self, participants: List[Router]) -> None: + cluster_ids = set(self.__clusters.keys()) + participant_names = {router.getName() for router in participants} + + for router in participants: + cluster_id = router.getBgpClusterId() + assert cluster_id is not None, ( + "Router {} in AS{} is missing a cluster ID".format(router.getName(), self.__asn) + ) + assert cluster_id in cluster_ids, ( + "Router {} in AS{} joined unknown cluster {}".format( + router.getName(), self.__asn, cluster_id + ) + ) + + data = self._aggregateBgpClusters(validate=False) + data = { + cid: (rrs & participant_names, clients & participant_names) + for cid, (rrs, clients) in data.items() + } + self._validate_cluster_integrity(data) + self.__clusters = data + + def _aggregateBgpClusters(self, validate: bool = True) -> Dict[str, Tuple[Set[str], Set[str]]]: """! @brief Build Route Reflector cluster membership from AS/router state. @@ -105,9 +401,8 @@ def _aggregateBgpClusters(self) -> Dict[str, Tuple[Set[str], Set[str]]]: cid: (set(rrs), set(clients)) for cid, (rrs, clients) in self.__clusters.items() } - default_cluster_id = "10.0.0.0" - for router in self.__routers.values(): + for router in self.__getIbgpParticipantRouters(): r_cid = router.getBgpClusterId() is_rr = router.isRouteReflector() r_name = router.getName() @@ -118,16 +413,18 @@ def _aggregateBgpClusters(self) -> Dict[str, Tuple[Set[str], Set[str]]]: ) target_cid = r_cid else: - if default_cluster_id not in merged_data: - merged_data[default_cluster_id] = (set(), set()) - target_cid = default_cluster_id + assert len(merged_data) == 1, ( + "Router {} in AS{} is missing a cluster ID".format(r_name, self.__asn) + ) + target_cid = next(iter(merged_data.keys())) if is_rr: merged_data[target_cid][0].add(r_name) else: merged_data[target_cid][1].add(r_name) - self._validate_cluster_integrity(merged_data) + if validate: + self._validate_cluster_integrity(merged_data) self.__clusters = merged_data return self.__clusters diff --git a/seedemu/core/Node.py b/seedemu/core/Node.py index cffc43125..763968b71 100644 --- a/seedemu/core/Node.py +++ b/seedemu/core/Node.py @@ -20,6 +20,10 @@ DEFAULT_SOFTWARE: List[str] = ['zsh', 'curl', 'nano', 'vim-nox', 'mtr-tiny', 'iproute2', 'iputils-ping', 'tcpdump', 'termshark', 'dnsutils', 'jq', 'ipcalc', 'netcat-openbsd'] +ROUTER_BGP_ROLE_EDGE = "edge" +ROUTER_BGP_ROLE_CORE = "core" +ROUTER_BGP_ROLES = {ROUTER_BGP_ROLE_EDGE, ROUTER_BGP_ROLE_CORE} + class File(Printable): """! @brief File class. @@ -1137,6 +1141,8 @@ class Router(Node): __is_bgp_rr: bool __bgp_cluster_id: Optional[str] __routing_backend: str + __bgp_role: Optional[str] + __disabled_control_planes: Set[str] __extensions: Dict[str, RouterExtension] def __init__(self, name: str, role: NodeRole, asn: int, scope: str = None, routingBackend: str = "bird"): @@ -1145,10 +1151,68 @@ def __init__(self, name: str, role: NodeRole, asn: int, scope: str = None, routi self.__is_bgp_rr = False self.__bgp_cluster_id = None self.__routing_backend = "bird" + self.__bgp_role = None + self.__disabled_control_planes = set() self.__extensions = {} super().__init__( name,role,asn,scope) self.setRoutingBackend(routingBackend) + def setBgpRole(self, role: str) -> Router: + """! + @brief Set this router's BGP participation role. + + This does not change the structural NodeRole. It is an AS-level routing + hint used by iBGP designs such as edge-only BGP with a BGP-free core. + + @param role edge or core. + + @returns self, for chaining API calls. + """ + value = str(role or "").strip().lower() + assert value in ROUTER_BGP_ROLES, "unsupported BGP role: {}. valid values: {}".format( + role, sorted(ROUTER_BGP_ROLES) + ) + self.__bgp_role = value + self.setLabel("seedemu_bgp_role", value) + return self + + def getBgpRole(self) -> Optional[str]: + """! + @brief Get this router's optional BGP participation role. + + @returns role name, or None if no role was set. + """ + return self.__bgp_role + + def disableControlPlane(self, protocol: str) -> Router: + """! + @brief Disable a protocol-layer participation hint on this router. + + The first supported flag is ibgp. It lets new opt-in iBGP modes exclude + a specific router without changing AS-wide legacy defaults. + + @param protocol protocol participation flag to disable. + + @returns self, for chaining API calls. + """ + value = str(protocol or "").strip().lower() + assert value in {"ibgp"}, "unsupported router control-plane disable flag: {}".format(protocol) + self.__disabled_control_planes.add(value) + self.setLabel("seedemu_control_plane_disabled_{}".format(value), "true") + return self + + def isControlPlaneDisabled(self, protocol: str) -> bool: + """! + @brief Check whether a protocol-layer participation hint is disabled. + """ + return str(protocol or "").strip().lower() in self.__disabled_control_planes + + def getDisabledControlPlanes(self) -> Set[str]: + """! + @brief Get disabled protocol-layer participation hints. + """ + return set(self.__disabled_control_planes) + def makeRouteReflector(self, is_rr: bool = True) -> Router: """! @brief Mark this router as an iBGP Route Reflector. diff --git a/seedemu/layers/Ebgp.py b/seedemu/layers/Ebgp.py index c9270d588..b63425936 100644 --- a/seedemu/layers/Ebgp.py +++ b/seedemu/layers/Ebgp.py @@ -1,7 +1,7 @@ from __future__ import annotations from seedemu.core import Registry, ScopedRegistry, Network, Interface, Graphable, Emulator, Layer, Router from seedemu.core.enums import NodeRole -from typing import Tuple, List, Dict +from typing import Tuple, List, Dict, Optional from enum import Enum from ._bgp_metadata import ( BGP_COMMUNITY_CUSTOMER, @@ -37,7 +37,9 @@ class Ebgp(Layer, Graphable): """ __peerings: Dict[Tuple[int, int, int], PeerRelationship] + __peering_routers: Dict[Tuple[int, int, int], Tuple[Optional[str], Optional[str]]] __rs_peers: List[Tuple[int, int]] + __rs_peer_routers: Dict[Tuple[int, int], str] __xc_peerings: Dict[Tuple[int, int], PeerRelationship] def __init__(self): @@ -46,8 +48,10 @@ def __init__(self): """ super().__init__() self.__peerings = {} + self.__peering_routers = {} self.__xc_peerings = {} self.__rs_peers = [] + self.__rs_peer_routers = {} self.addDependency('Routing', False, False) def __recordPeer( @@ -139,10 +143,44 @@ def __createPeer(self, nodeA: Router, nodeB: Router, addrA: str, addrB: str, rel self.__recordPeer(routerA, 'x_as{}'.format(routerB.getAsn()), addrA, routerA.getAsn(), addrB, routerB.getAsn(), BGP_COMMUNITY_CUSTOMER, 30, BGP_EXPORT_ALL) self.__recordPeer(routerB, 'x_as{}'.format(routerA.getAsn()), addrB, routerB.getAsn(), addrA, routerA.getAsn(), BGP_COMMUNITY_PROVIDER, 10, BGP_EXPORT_ALL) + def __selectIxRouter( + self, + candidates: List[Router], + ixNet: Network, + ix: int, + asn: int, + routerName: Optional[str] = None, + ) -> Tuple[Router, Interface]: + """Resolve the router/interface used by an IX peering intent.""" + if routerName is not None: + for node in candidates: + if node.getName() != routerName: + continue + for iface in node.getInterfaces(): + if iface.getNet() == ixNet: + return node, iface + assert False, 'explicit peering router as{}/{} is not connected to ix{}'.format(asn, routerName, ix) + assert False, 'explicit peering router as{}/{} does not exist for ix{}'.format(asn, routerName, ix) + + for node in candidates: + for iface in node.getInterfaces(): + if iface.getNet() == ixNet: + return node, iface + + assert False, 'cannot resolve peering: as{} not in ix{}'.format(asn, ix) + def getName(self) -> str: return "Ebgp" - def addPrivatePeering(self, ix: int, a: int, b: int, abRelationship: PeerRelationship = PeerRelationship.Peer) -> Ebgp: + def addPrivatePeering( + self, + ix: int, + a: int, + b: int, + abRelationship: PeerRelationship = PeerRelationship.Peer, + aRouter: str = None, + bRouter: str = None, + ) -> Ebgp: """! @brief Setup private peering between two ASes in IX. @@ -163,9 +201,37 @@ def addPrivatePeering(self, ix: int, a: int, b: int, abRelationship: PeerRelatio assert abRelationship == PeerRelationship.Peer or abRelationship == PeerRelationship.Provider or abRelationship == PeerRelationship.Unfiltered, 'unknown peering relationship {}'.format(abRelationship) self.__peerings[(ix, a, b)] = abRelationship + if aRouter is not None or bRouter is not None: + self.__peering_routers[(ix, a, b)] = (aRouter, bRouter) return self + def addPrivatePeeringByRouters( + self, + ix: int, + a: int, + aRouter: str, + b: int, + bRouter: str, + abRelationship: PeerRelationship = PeerRelationship.Peer, + ) -> Ebgp: + """! + @brief Setup private peering using explicit router names on the IX. + + This is the preferred API when one AS has multiple routers attached to + the same IX and the peering must bind to a specific edge router. + + @param ix IXP id. + @param a First ASN. + @param aRouter router in AS a connected to the IX. + @param b Second ASN. + @param bRouter router in AS b connected to the IX. + @param abRelationship peering relationship. + + @returns self, for chaining API calls. + """ + return self.addPrivatePeering(ix, a, b, abRelationship, aRouter, bRouter) + def addPrivatePeerings(self, ix: int, a_asns: List[int], b_asns: List[int], abRelationship: PeerRelationship = PeerRelationship.Peer) -> Ebgp: """! @brief Setup private peering between two sets of ASes in IX. @@ -196,6 +262,12 @@ def getPrivatePeerings(self) -> Dict[Tuple[int, int, int], PeerRelationship]: """ return self.__peerings + def getPrivatePeeringRouters(self) -> Dict[Tuple[int, int, int], Tuple[Optional[str], Optional[str]]]: + """! + @brief Get explicit router selections for private IX peerings. + """ + return dict(self.__peering_routers) + def addCrossConnectPeering(self, a: int, b: int, abRelationship: PeerRelationship = PeerRelationship.Peer) -> Ebgp: """! @brief add cross-connect peering. @@ -227,12 +299,13 @@ def getCrossConnectPeerings(self) -> Dict[Tuple[int, int], PeerRelationship]: """ return self.__xc_peerings - def addRsPeer(self, ix: int, peer: int) -> Ebgp: + def addRsPeer(self, ix: int, peer: int, routerName: str = None) -> Ebgp: """! @brief Setup RS peering for an AS. @param ix IXP id. @param peer Participant ASN. + @param routerName optional router in the participant AS connected to the IX. @throws AssertionError if peering already exist. @@ -241,9 +314,23 @@ def addRsPeer(self, ix: int, peer: int) -> Ebgp: assert (ix, peer) not in self.__rs_peers, '{} already peered with RS at IX{}'.format(peer, ix) self.__rs_peers.append((ix, peer)) + if routerName is not None: + self.__rs_peer_routers[(ix, peer)] = routerName return self + def addRsPeerByRouter(self, ix: int, peer: int, routerName: str) -> Ebgp: + """! + @brief Setup RS peering using an explicit participant router name. + + @param ix IXP id. + @param peer Participant ASN. + @param routerName router in the participant AS connected to the IX. + + @returns self, for chaining API calls. + """ + return self.addRsPeer(ix, peer, routerName) + def addRsPeers(self, ix: int, peers: List[int]): """! @brief Setup RS peering for list of ASes. @@ -268,6 +355,12 @@ def getRsPeers(self) -> List[Tuple[int, int]]: """ return self.__rs_peers + def getRsPeerRouters(self) -> Dict[Tuple[int, int], str]: + """! + @brief Get explicit router selections for RS peerings. + """ + return dict(self.__rs_peer_routers) + def configure(self, emulator: Emulator) -> None: reg = emulator.getRegistry() @@ -282,17 +375,13 @@ def configure(self, emulator: Emulator) -> None: rs_if = rs_ifs[0] p_rnodes: List[Router] = p_reg.getByType('brdnode') - p_ixnode: Router = None - p_ixif: Interface = None - for node in p_rnodes: - if p_ixnode != None: break - for iface in node.getInterfaces(): - if iface.getNet() == ix_net: - p_ixnode = node - p_ixif = iface - break - - assert p_ixnode != None, 'cannot resolve peering: as{} not in ix{}'.format(peer, ix) + p_ixnode, p_ixif = self.__selectIxRouter( + p_rnodes, + ix_net, + ix, + peer, + self.__rs_peer_routers.get((ix, peer)), + ) self._log("adding peering: {} as {} (RS) <-> {} as {}".format(rs_if.getAddress(), ix, p_ixif.getAddress(), peer)) self.__createPeer(ix_rs, p_ixnode, rs_if.getAddress(), p_ixif.getAddress(), PeerRelationship.Peer) @@ -340,30 +429,9 @@ def configure(self, emulator: Emulator) -> None: ix_net: Network = ix_reg.get('net', 'ix{}'.format(ix)) a_rnodes: List[Router] = a_reg.getByType('rnode') b_rnodes: List[Router] = b_reg.getByType('rnode') - - a_ixnode: Router = None - a_ixif: Interface = None - for node in a_rnodes: - if a_ixnode != None: break - for iface in node.getInterfaces(): - if iface.getNet() == ix_net: - a_ixnode = node - a_ixif = iface - break - - assert a_ixnode != None, 'cannot resolve peering: as{} not in ix{}'.format(a, ix) - - b_ixnode: Router = None - b_ixif: Interface = None - for node in b_rnodes: - if b_ixnode != None: break - for iface in node.getInterfaces(): - if iface.getNet() == ix_net: - b_ixnode = node - b_ixif = iface - break - - assert b_ixnode != None, 'cannot resolve peering: as{} not in ix{}'.format(b, ix) + a_router, b_router = self.__peering_routers.get((ix, a, b), (None, None)) + a_ixnode, a_ixif = self.__selectIxRouter(a_rnodes, ix_net, ix, a, a_router) + b_ixnode, b_ixif = self.__selectIxRouter(b_rnodes, ix_net, ix, b, b_router) self._log("adding IX peering: {} as {} <-({})-> {} as {}".format(a_ixif.getAddress(), a, rel, b_ixif.getAddress(), b)) diff --git a/seedemu/layers/Ibgp.py b/seedemu/layers/Ibgp.py index 0726c20c7..ef75ac14a 100644 --- a/seedemu/layers/Ibgp.py +++ b/seedemu/layers/Ibgp.py @@ -5,6 +5,16 @@ from typing import Dict, List, Set, Tuple from ._bgp_metadata import install_router_bgp_session +IBGP_MODE_FULL_MESH = "full-mesh" +IBGP_MODE_ROUTE_REFLECTOR = "route-reflector" +IBGP_MODE_DISABLED = "disabled" + +IBGP_MODES = { + IBGP_MODE_FULL_MESH, + IBGP_MODE_ROUTE_REFLECTOR, + IBGP_MODE_DISABLED, +} + class Ibgp(Layer, Graphable): """! @@ -79,25 +89,53 @@ def getMaskedAsns(self) -> Set[int]: """ return self.__masked + def __is_ibgp_disabled(self, router: Node) -> bool: + return hasattr(router, "isControlPlaneDisabled") and router.isControlPlaneDisabled("ibgp") + + def __get_igp_table(self, emulator: Emulator, asn: int) -> str: + reg = emulator.getRegistry() + if reg.has('seedemu', 'layer', 'Mpls'): + mpls = reg.get('seedemu', 'layer', 'Mpls') + if hasattr(mpls, "getEnabled") and asn in mpls.getEnabled(): + return "master4" + return "t_ospf" + def configure(self, emulator: Emulator): reg = emulator.getRegistry() base: Base = reg.get('seedemu', 'layer', 'Base') for asn in base.getAsns(): - if asn in self.__masked: continue + asobj = base.getAutonomousSystem(asn) + if int(asn) in self.__masked: + asobj.setIbgpMode(IBGP_MODE_DISABLED) + + asobj.completeIbgpSetup() + mode = asobj.getIbgpMode() + if mode == IBGP_MODE_DISABLED: continue self._log('setting up IBGP peering for as{}...'.format(asn)) routers: List[Node] = ScopedRegistry(str(asn), reg).getByType('rnode') routers_map: Dict[str, Node] = {router.getName(): router for router in routers} + participant_names = asobj.getIbgpParticipants() + disabled = {name for name, router in routers_map.items() if self.__is_ibgp_disabled(router)} + active_names = participant_names - disabled + active_routers_map = { + name: router for name, router in routers_map.items() + if name in active_names + } + igp_table = self.__get_igp_table(emulator, asn) + + if mode == IBGP_MODE_ROUTE_REFLECTOR: + clusters = asobj._aggregateBgpClusters() + self._render_rr_mode(asn, clusters, active_routers_map, igp_table) + continue - clusters = base.getAutonomousSystem(asn)._aggregateBgpClusters() - has_rr = any(len(rrs) > 0 for rrs, _ in clusters.values()) - - if has_rr: - self._render_rr_mode(asn, clusters, routers_map) - else: - self._render_full_mesh_mode(asn, routers) + self._render_full_mesh_mode( + asn, + [active_routers_map[name] for name in sorted(active_routers_map.keys())], + igp_table, + ) - def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str]]], routers_map: Dict[str, Node]): + def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str]]], routers_map: Dict[str, Node], igp_table: str = "t_ospf"): """! @brief Render Route Reflector based iBGP sessions for one AS. @@ -136,7 +174,7 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "peer_asn": asn, "export_policy": "all", "next_hop_self": False, - "igp_table": "t_ospf", + "igp_table": igp_table, "passive": True, "route_reflector_client": True, "route_reflector_cluster_id": cluster_id, @@ -153,7 +191,7 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "peer_asn": asn, "export_policy": "all", "next_hop_self": True, - "igp_table": "t_ospf", + "igp_table": igp_table, }, ) @@ -184,7 +222,7 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "peer_asn": asn, "export_policy": "all", "next_hop_self": False, - "igp_table": "t_ospf", + "igp_table": igp_table, }, ) install_router_bgp_session( @@ -198,7 +236,7 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "peer_asn": asn, "export_policy": "all", "next_hop_self": False, - "igp_table": "t_ospf", + "igp_table": igp_table, }, ) @@ -208,7 +246,7 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] ) ) - def _render_full_mesh_mode(self, asn: int, routers: List[Node]): + def _render_full_mesh_mode(self, asn: int, routers: List[Node], igp_table: str = "t_ospf"): """! @brief Render the legacy full-mesh iBGP sessions for one AS. @@ -216,6 +254,7 @@ def _render_full_mesh_mode(self, asn: int, routers: List[Node]): @param routers routers participating in the legacy iBGP mesh. """ self._log('setting up IBGP (Full Mesh) for as{}...'.format(asn)) + allowed_names = {router.getName() for router in routers} for local in routers: self._log('setting up IBGP peering on as{}/{}...'.format(asn, local.getName())) @@ -227,6 +266,8 @@ def _render_full_mesh_mode(self, asn: int, routers: List[Node]): for remote in remotes: if local == remote: continue + if remote.getName() not in allowed_names: + continue laddr = local.getLoopbackAddress() raddr = remote.getLoopbackAddress() @@ -241,7 +282,7 @@ def _render_full_mesh_mode(self, asn: int, routers: List[Node]): "peer_asn": asn, "export_policy": "all", "next_hop_self": False, - "igp_table": "t_ospf", + "igp_table": igp_table, }, ) @@ -255,8 +296,12 @@ def render(self, emulator: Emulator): def _doCreateGraphs(self, emulator: Emulator): base: Base = emulator.getRegistry().get('seedemu', 'layer', 'Base') for asn in base.getAsns(): - if asn in self.__masked: continue asobj = base.getAutonomousSystem(asn) + if int(asn) in self.__masked: + asobj.setIbgpMode(IBGP_MODE_DISABLED) + asobj.completeIbgpSetup() + mode = asobj.getIbgpMode() + if mode == IBGP_MODE_DISABLED: continue asobj.createGraphs(emulator) l2graph = asobj.getGraph('AS{}: Layer 2 Connections'.format(asn)) ibgpgraph = self._addGraph('AS{}: iBGP sessions'.format(asn), False) @@ -264,8 +309,33 @@ def _doCreateGraphs(self, emulator: Emulator): for edge in ibgpgraph.edges: edge.style = 'dotted' - rtrs = ScopedRegistry(str(asn), emulator.getRegistry()).getByType('rnode').copy() - + scope = ScopedRegistry(str(asn), emulator.getRegistry()) + rtrs = scope.getByType('rnode').copy() + routers_map: Dict[str, Node] = {router.getName(): router for router in rtrs} + disabled = {name for name, router in routers_map.items() if self.__is_ibgp_disabled(router)} + active_names = asobj.getIbgpParticipants() - disabled + + if mode == IBGP_MODE_ROUTE_REFLECTOR: + clusters = asobj._aggregateBgpClusters() + all_rrs: Set[str] = set() + for _cluster_id, (rr_names, client_names) in clusters.items(): + all_rrs.update(rr_names) + for rr_name in rr_names: + for client_name in client_names: + if rr_name in active_names and client_name in active_names: + ibgpgraph.addEdge( + 'Router: {}'.format(rr_name), + 'Router: {}'.format(client_name), + style = 'solid' + ) + rtrs = [ + routers_map[name] + for name in sorted(all_rrs) + if name in routers_map and name in active_names + ] + else: + rtrs = [routers_map[name] for name in sorted(active_names) if name in routers_map] + while len(rtrs) > 0: a = rtrs.pop() for b in rtrs: diff --git a/seedemu/layers/Mpls.py b/seedemu/layers/Mpls.py index 9aa3791b3..e1395537e 100644 --- a/seedemu/layers/Mpls.py +++ b/seedemu/layers/Mpls.py @@ -1,12 +1,19 @@ from __future__ import annotations +from .Base import Base from .Ospf import Ospf from .Ibgp import Ibgp from seedemu.core import Node, ScopedRegistry, Graphable, Emulator, Layer, Router from seedemu.core.enums import NetworkType, NodeRole from typing import List, Tuple, Dict, Set +from ._bgp_metadata import install_router_bgp_session MplsFileTemplates: Dict[str, str] = {} +MPLS_PRESERVED_IBGP_MODES = {"route-reflector"} +MPLS_DISABLED_IBGP_MODES = {"disabled"} +MPLS_CORE_FORWARDING = "mpls" +MPLS_EDGE_BGP_SCOPE = "edge-only" + MplsFileTemplates['frr_start_script'] = """\ #!/bin/bash mount -o remount rw /proc/sys 2> /dev/null @@ -40,18 +47,6 @@ ip ospf dead-interval minimal hello-multiplier 2 """ -MplsFileTemplates['bird_ibgp_peer'] = ''' - ipv4 {{ - table t_bgp; - import all; - export all; - igp table master4; - }}; - local {localAddress} as {asn}; - neighbor {peerAddress} as {asn}; -''' - - class Mpls(Layer, Graphable): """! @brief The Mpls (MPLS) layer. @@ -151,16 +146,58 @@ def getEnabled(self) -> Set[int]: """ return self.__enabled + def __syncAsLevelEnabled(self, emulator: Emulator) -> Set[int]: + reg = emulator.getRegistry() + if reg.has('seedemu', 'layer', 'Base'): + base: Base = reg.get('seedemu', 'layer', 'Base') + for asn in base.getAsns(): + asobj = base.getAutonomousSystem(asn) + if hasattr(asobj, "getCoreForwarding") and asobj.getCoreForwarding() == MPLS_CORE_FORWARDING: + self.__enabled.add(asn) + return set(self.__enabled) + + def __getAsObject(self, emulator: Emulator, asn: int): + reg = emulator.getRegistry() + if reg.has('seedemu', 'layer', 'Base'): + base: Base = reg.get('seedemu', 'layer', 'Base') + return base.getAutonomousSystem(asn) + return None + + def __getExplicitIbgpMode(self, emulator: Emulator, asn: int): + asobj = self.__getAsObject(emulator, asn) + if asobj is not None: + if hasattr(asobj, "hasIbgpMode") and asobj.hasIbgpMode(): + return asobj.getIbgpMode() + reg = emulator.getRegistry() + if reg.has('seedemu', 'layer', 'Ibgp'): + ibgp: Ibgp = reg.get('seedemu', 'layer', 'Ibgp') + if asn in ibgp.getMaskedAsns(): + return "disabled" + return None + + def __preserveExistingIbgp(self, emulator: Emulator, asn: int) -> bool: + asobj = self.__getAsObject(emulator, asn) + if asobj is not None: + if asobj.getIbgpMode() in MPLS_PRESERVED_IBGP_MODES: + return True + if asobj.getIbgpMode() == "full-mesh" and asobj.getBgpScope() == MPLS_EDGE_BGP_SCOPE: + return True + return self.__getExplicitIbgpMode(emulator, asn) in MPLS_PRESERVED_IBGP_MODES + + def __installMplsIbgp(self, emulator: Emulator, asn: int) -> bool: + mode = self.__getExplicitIbgpMode(emulator, asn) + return mode not in MPLS_PRESERVED_IBGP_MODES and mode not in MPLS_DISABLED_IBGP_MODES + def __maskExistingControlPlaneLayers(self, emulator: Emulator) -> None: """Mask OSPF/iBGP before those layers record non-MPLS intent.""" reg = emulator.getRegistry() - for asn in self.__enabled: + for asn in self.__syncAsLevelEnabled(emulator): if reg.has('seedemu', 'layer', 'Ospf'): self._log('Ospf layer exists, masking as{}'.format(asn)) ospf: Ospf = reg.get('seedemu', 'layer', 'Ospf') ospf.maskAsn(asn) - if reg.has('seedemu', 'layer', 'Ibgp'): + if reg.has('seedemu', 'layer', 'Ibgp') and not self.__preserveExistingIbgp(emulator, asn): self._log('Ibgp layer exists, masking as{}'.format(asn)) ibgp: Ibgp = reg.get('seedemu', 'layer', 'Ibgp') ibgp.maskAsn(asn) @@ -192,6 +229,17 @@ def __getEdgeNodes(self, scope: ScopedRegistry) -> Tuple[List[Node], List[Node]] return (enodes, nodes) + def __addAdditionalEdges(self, scope: ScopedRegistry, asn: int, enodes: List[Node]) -> List[Node]: + names = {node.getName() for node in enodes} + for (asn_, nodename) in self.__additional_edges: + if asn_ != asn: + continue + if scope.has('rnode', nodename) and nodename not in names: + node = scope.get('rnode', nodename) + enodes.append(node) + names.add(nodename) + return enodes + def __setUpLdpOspf(self, node: Router): """! @brief Setup LDP and OSPF on router. @@ -242,41 +290,56 @@ def __setUpIbgpMesh(self, nodes: List[Router]): for remote in nodes: if local == remote: continue - local.addTable('t_bgp') - local.addTablePipe('t_bgp') - local.addTablePipe('t_direct', 't_bgp') - local.addProtocol('bgp', 'ibgp{}'.format(n), MplsFileTemplates['bird_ibgp_peer'].format( - localAddress = local.getLoopbackAddress(), - peerAddress = remote.getLoopbackAddress(), - asn = local.getAsn() - )) + install_router_bgp_session( + local, + { + "name": "mpls_ibgp{}".format(n), + "kind": "ibgp", + "local_address": str(local.getLoopbackAddress()), + "local_asn": local.getAsn(), + "peer_address": str(remote.getLoopbackAddress()), + "peer_asn": local.getAsn(), + "export_policy": "all", + "next_hop_self": False, + "igp_table": "master4", + }, + ) n += 1 def configure(self, emulator: Emulator): super().configure(emulator) + enabled = self.__syncAsLevelEnabled(emulator) + install_mpls_ibgp = { + asn: self.__installMplsIbgp(emulator, asn) + for asn in enabled + } self.__maskExistingControlPlaneLayers(emulator) + reg = emulator.getRegistry() + for asn in enabled: + scope = ScopedRegistry(str(asn), reg) + (enodes, _) = self.__getEdgeNodes(scope) + enodes = self.__addAdditionalEdges(scope, asn, enodes) + if install_mpls_ibgp[asn]: + self.__setUpIbgpMesh(enodes) def render(self, emulator: Emulator): reg = emulator.getRegistry() + enabled = self.__syncAsLevelEnabled(emulator) self.__maskExistingControlPlaneLayers(emulator) - for asn in self.__enabled: + for asn in enabled: scope = ScopedRegistry(str(asn), reg) (enodes, nodes) = self.__getEdgeNodes(scope) - - for (asn_, nodename) in self.__additional_edges: - if asn_ != asn: continue - if scope.has('rnode', nodename): - enodes.append(scope.get('rnode', nodename)) + enodes = self.__addAdditionalEdges(scope, asn, enodes) for n in enodes: self.__setUpLdpOspf(n) for n in nodes: self.__setUpLdpOspf(n) - self.__setUpIbgpMesh(enodes) def _doCreateGraphs(self, emulator: Emulator): base = emulator.getRegistry().get('seedemu', 'layer', 'Base') + enabled = self.__syncAsLevelEnabled(emulator) for asn in base.getAsns(): - if asn not in self.__enabled: continue + if asn not in enabled: continue asobj = base.getAutonomousSystem(asn) asobj.createGraphs(emulator) l2graph = asobj.getGraph('AS{}: Layer 2 Connections'.format(asn)) diff --git a/seedemu/layers/Ospf.py b/seedemu/layers/Ospf.py index 64f1cad20..50051096c 100644 --- a/seedemu/layers/Ospf.py +++ b/seedemu/layers/Ospf.py @@ -1,11 +1,16 @@ from __future__ import annotations from seedemu.core import Node, Emulator, Layer -from seedemu.core.enums import NetworkType +from seedemu.core.enums import NetworkType, NodeRole from typing import Set, Dict, List, Tuple +from .Base import Base from ._bgp_metadata import classify_ospf_interfaces, set_ospf_interface_intents OspfFileTemplates: Dict[str, str] = {} +OSPF_MODE_LEGACY = "legacy" +OSPF_MODE_ROUTER_TRANSIT_ONLY = "router-transit-only" +OSPF_MODES = {OSPF_MODE_LEGACY, OSPF_MODE_ROUTER_TRANSIT_ONLY} + class Ospf(Layer): """! @brief Ospf (OSPF) layer. @@ -124,8 +129,48 @@ def isMasked(self, asn: int, netname: str) -> bool: """ return (asn, netname) in self.__masked + def __is_router_transit_network(self, node: Node, netname: str) -> bool: + for iface in node.getInterfaces(): + net = iface.getNet() + if str(net.getName()) != str(netname): + continue + if net.getType() != NetworkType.Local: + return False + router_count = 0 + for candidate in net.getAssociations(): + role = candidate.getRole() + if role in {NodeRole.Router, NodeRole.BorderRouter, NodeRole.OpenVpnRouter}: + router_count += 1 + return router_count >= 2 + return False + + def __classify_router_transit_interfaces( + self, + router: Node, + stubs: List[str], + masked: List[str] + ) -> Tuple[List[str], List[str]]: + stub_names = {str(name) for name in stubs} + masked_names = {str(name) for name in masked} + active: List[str] = [] + passive: List[str] = ["dummy0"] + for iface in router.getInterfaces(): + net = iface.getNet() + name = str(net.getName()) + if name in masked_names: + continue + if name in stub_names: + passive.append(name) + continue + if self.__is_router_transit_network(router, name): + active.append(name) + else: + passive.append(name) + return active, passive + def configure(self, emulator: Emulator): reg = emulator.getRegistry() + base: Base = reg.get('seedemu', 'layer', 'Base') for ((scope, type, name), obj) in reg.getAll().items(): if type != 'rnode': continue @@ -133,11 +178,21 @@ def configure(self, emulator: Emulator): if router.getAsn() in self.__masked_asn: continue self._log('setting up OSPF for router as{}/{}...'.format(scope, name)) - active, stubs = classify_ospf_interfaces( - router, - stubs=[net for (asn, net) in self.__stubs if asn == int(scope)], - masked=[net for (asn, net) in self.__masked if asn == int(scope)], - ) + asobj = base.getAutonomousSystem(int(scope)) + stub_networks = [net for (asn, net) in self.__stubs if asn == int(scope)] + masked_networks = [net for (asn, net) in self.__masked if asn == int(scope)] + if asobj.getOspfMode() == OSPF_MODE_ROUTER_TRANSIT_ONLY: + active, stubs = self.__classify_router_transit_interfaces( + router, + stubs=stub_networks, + masked=masked_networks, + ) + else: + active, stubs = classify_ospf_interfaces( + router, + stubs=stub_networks, + masked=masked_networks, + ) set_ospf_interface_intents(router, active, stubs) def render(self, emulator: Emulator): diff --git a/seedemu/layers/Routing.py b/seedemu/layers/Routing.py index 93c85216f..f46a7f065 100644 --- a/seedemu/layers/Routing.py +++ b/seedemu/layers/Routing.py @@ -222,22 +222,22 @@ def _installFrr(self, node: Node): def _configure_rs(self, rs_node: Node): backend = get_bgp_backend(rs_node) - if backend == BGP_BACKEND_FRR: - raise NotImplementedError("FRR route-server nodes are not supported yet; use BIRD route servers") - rs_node.appendStartCommand('[ ! -d /run/bird ] && mkdir /run/bird') - rs_node.appendStartCommand('bird -d', True) - self._log("Bootstrapping bird.conf for RS {}...".format(rs_node.getName())) - rs_ifaces = rs_node.getInterfaces() assert len(rs_ifaces) == 1, "rs node {} has != 1 interfaces".format(rs_node.getName()) - rs_iface = rs_ifaces[0] assert issubclass(rs_node.__class__, Router) rs_node.setBorderRouter(True) - rs_node.setFile("/etc/bird/bird.conf", RoutingFileTemplates["rs_bird"].format( - routerId = rs_iface.getAddress() - )) + if backend == BGP_BACKEND_BIRD: + rs_node.appendStartCommand('[ ! -d /run/bird ] && mkdir /run/bird') + rs_node.appendStartCommand('bird -d', True) + self._log("Bootstrapping bird.conf for RS {}...".format(rs_node.getName())) + rs_node.setFile("/etc/bird/bird.conf", RoutingFileTemplates["rs_bird"].format( + routerId = rs_iface.getAddress() + )) + else: + self._log("Bootstrapping frr.conf for RS {}...".format(rs_node.getName())) + self._configure_frr_router(rs_node) def _configure_bird_router(self, rnode: Router): ifaces = '' @@ -349,14 +349,17 @@ def _render_frr_bgp(self, router: Router) -> str: customer_comm="{}:1:0".format(router.getAsn()), ) ) - connected_body, has_connected = self._render_frr_connected_export(router) + if has_bgp_connected_export(router): + connected_body, has_connected = self._render_frr_connected_export(router) + else: + connected_body, has_connected = "", False body.append(connected_body) route_maps, map_names = self._render_frr_route_maps(router.getAsn(), sessions) body.append(route_maps) bgp: List[str] = [ "router bgp {}".format(router.getAsn()), - " bgp router-id {}".format(router.getLoopbackAddress()), + " bgp router-id {}".format(self._get_frr_router_id(router)), " no bgp ebgp-requires-policy", " no bgp default ipv4-unicast", ] @@ -389,6 +392,8 @@ def _render_frr_bgp(self, router: Router) -> str: bgp.append(" neighbor {} activate".format(session["peer_address"])) if session["next_hop_self"]: bgp.append(" neighbor {} next-hop-self".format(session["peer_address"])) + if session["route_server_client"]: + bgp.append(" neighbor {} route-server-client".format(session["peer_address"])) if session["route_reflector_client"]: bgp.append(" neighbor {} route-reflector-client".format(session["peer_address"])) if maps.get("import"): @@ -400,6 +405,14 @@ def _render_frr_bgp(self, router: Router) -> str: body.append("\n".join(bgp) + "\n") return "".join(body) + def _get_frr_router_id(self, router: Router) -> str: + loopback = router.getLoopbackAddress() + if loopback is not None: + return str(loopback) + ifaces = router.getInterfaces() + assert len(ifaces) > 0, "router node {}/{} has no interfaces".format(router.getAsn(), router.getName()) + return str(ifaces[0].getAddress()) + def _render_frr_ospf(self, router: Router) -> str: intents = get_ospf_interface_intents(router) if not intents["active"] and not intents["passive"]: @@ -429,7 +442,11 @@ def configure(self, emulator: Emulator): for ((scope, type, name), obj) in reg.getAll().items(): if type == 'rs': rs_node: Node = obj - self._installBird(rs_node) + backend = get_bgp_backend(rs_node) + if backend == BGP_BACKEND_BIRD: + self._installBird(rs_node) + else: + self._installFrr(rs_node) self._configure_rs(rs_node) if type == 'rnode': rnode: Router = obj @@ -496,7 +513,12 @@ def render(self, emulator: Emulator): assert issubclass(obj.__class__, Router), 'routing: render: adding new RS/Router after routing layer configured is not currently supported.' if type == 'rs': - self._render_bird_control_plane(obj) + rs_node: Router = obj + backend = get_bgp_backend(rs_node) + if backend == BGP_BACKEND_BIRD: + self._render_bird_control_plane(rs_node) + else: + self._render_frr_control_plane(rs_node) if type == 'rnode': rnode: Router = obj diff --git a/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py b/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py index 47785ad9e..4fe24512a 100644 --- a/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py +++ b/seedemu/services/EthereumService/EthTemplates/EthServerFileTemplates.py @@ -23,7 +23,7 @@ def get_file_content(filename): 'fund_account': get_file_content("files_utility/fund_account.py"), 'deploy_contract': get_file_content("files_utility/deploy_contract.py"), 'utility_server': get_file_content("files_utility/utility_server.py"), - 'server_setup': get_file_content("files_utility/utility_server_setup.py") + 'server_setup': get_file_content("files_utility/utility_server_setup.sh") } FaucetServerFileTemplates: Dict[str, str] = { @@ -34,4 +34,3 @@ def get_file_content(filename): 'faucet_fund_url': "http://{address}:{port}/fundme", 'fund_curl': "curl -X POST -d 'address={recipient}&amount={amount}' http://{address}:{port}/fundme" } - diff --git a/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.py b/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.sh similarity index 99% rename from seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.py rename to seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.sh index d0666f97f..b0c38ab6f 100644 --- a/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.py +++ b/seedemu/services/EthereumService/EthTemplates/files_utility/utility_server_setup.sh @@ -5,4 +5,3 @@ python3 ./fund_account.py python3 ./deploy_contract.py - diff --git a/test_frr_exabgp_foundation.py b/test_frr_exabgp_foundation.py index c84b6f430..06e04db18 100644 --- a/test_frr_exabgp_foundation.py +++ b/test_frr_exabgp_foundation.py @@ -29,6 +29,27 @@ def test_router_backend_default_and_legacy_rejection(): as2.createRouter("external", routingBackend="external") +def test_as_level_control_plane_modes_are_explicit_intent(): + as2 = Base().createAutonomousSystem(2) + + assert as2.getIbgpMode() == "full-mesh" + assert not as2.hasIbgpMode() + assert as2.getOspfMode() == "legacy" + assert not as2.hasOspfMode() + + as2.setIbgpMode("route-reflector") + as2.setOspfMode("router-transit-only") + assert as2.getIbgpMode() == "route-reflector" + assert as2.hasIbgpMode() + assert as2.getOspfMode() == "router-transit-only" + assert as2.hasOspfMode() + + with pytest.raises(AssertionError, match="unsupported iBGP mode"): + as2.setIbgpMode("rr") + with pytest.raises(AssertionError, match="unsupported OSPF mode"): + as2.setOspfMode("host-facing") + + def test_frr_bgp_layer_shim_is_not_exported(): with pytest.raises(ModuleNotFoundError): importlib.import_module("seedemu.layers.FrrBgp") @@ -67,7 +88,7 @@ def test_route_server_peering_renders_from_intent_in_routing_layer(): assert "bgp_large_community.add(PEER_COMM)" in router_conf -def test_frr_route_server_backend_is_explicitly_rejected(): +def test_frr_route_server_backend_renders_route_server_client_config(): emu = Emulator() base = Base() routing = Routing() @@ -78,15 +99,31 @@ def test_frr_route_server_backend_is_explicitly_rejected(): as150 = base.createAutonomousSystem(150) as150.createNetwork("net0") as150.createRouter("router0").joinNetwork("net0").joinNetwork("ix100") + as151 = base.createAutonomousSystem(151) + as151.createNetwork("net0") + as151.createRouter("router0").joinNetwork("net0").joinNetwork("ix100") ebgp.addRsPeer(100, 150) + ebgp.addRsPeer(100, 151) emu.addLayer(base) emu.addLayer(routing) emu.addLayer(ebgp) + emu.render() - with pytest.raises(NotImplementedError, match="FRR route-server nodes are not supported yet"): - emu.render() + route_server = emu.getRegistry().get("ix", "rs", "ix100") + as150_router = emu.getRegistry().get("150", "rnode", "router0") + frr_conf = _file_content(route_server, "/etc/frr/frr.conf") + + assert _file_content(route_server, "/etc/bird/bird.conf") == "" + assert "router bgp 100" in frr_conf + assert " bgp router-id 10.100.0.100" in frr_conf + assert "neighbor 10.100.0.150 remote-as 150" in frr_conf + assert "neighbor 10.100.0.151 remote-as 151" in frr_conf + assert "neighbor 10.100.0.150 route-server-client" in frr_conf + assert "neighbor 10.100.0.151 route-server-client" in frr_conf + assert "redistribute connected" not in frr_conf + assert "protocol bgp p_rs100" in _file_content(as150_router, "/etc/bird/bird.conf") def test_frr_backend_renders_frr_config_for_selected_router(): @@ -142,6 +179,315 @@ def test_frr_backend_renders_frr_config_for_selected_router(): assert "interface net0" in frr_conf +def _build_three_router_ibgp_emulator(ibgp: Ibgp, configure_as=None): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + + as2 = base.createAutonomousSystem(2) + as2.createNetwork("west") + as2.createNetwork("east") + as2.createRouter("edge_west").joinNetwork("west") + as2.createRouter("core").joinNetwork("west").joinNetwork("east") + as2.createRouter("edge_east", routingBackend="frr").joinNetwork("east") + if configure_as is not None: + configure_as(as2) + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.addLayer(ibgp) + emu.render() + + return ( + emu.getRegistry().get("2", "rnode", "edge_west"), + emu.getRegistry().get("2", "rnode", "core"), + emu.getRegistry().get("2", "rnode", "edge_east"), + ) + + +def test_default_ibgp_mode_preserves_legacy_full_mesh(): + edge_west, core, edge_east = _build_three_router_ibgp_emulator(Ibgp()) + + assert len([session for session in get_bgp_sessions(edge_west) if session["kind"] == "ibgp"]) == 2 + assert len([session for session in get_bgp_sessions(core) if session["kind"] == "ibgp"]) == 2 + assert len([session for session in get_bgp_sessions(edge_east) if session["kind"] == "ibgp"]) == 2 + + +def test_edge_only_full_mesh_ibgp_scope_excludes_core_router(): + def configure_edge_only(as2): + as2.setIbgpMode("full-mesh") + as2.setBgpScope("edge-only") + as2.getRouter("edge_west").setBgpRole("edge") + as2.getRouter("edge_east").setBgpRole("edge") + as2.getRouter("core").setBgpRole("core") + + edge_west, core, edge_east = _build_three_router_ibgp_emulator( + Ibgp(), + configure_as=configure_edge_only, + ) + + west_sessions = [session for session in get_bgp_sessions(edge_west) if session["kind"] == "ibgp"] + east_sessions = [session for session in get_bgp_sessions(edge_east) if session["kind"] == "ibgp"] + assert len(west_sessions) == 1 + assert len(east_sessions) == 1 + assert west_sessions[0]["peer_address"] == str(edge_east.getLoopbackAddress()) + assert east_sessions[0]["peer_address"] == str(edge_west.getLoopbackAddress()) + assert [session for session in get_bgp_sessions(core) if session["kind"] == "ibgp"] == [] + + +def test_router_bgp_roles_drive_edge_only_full_mesh_scope(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + ibgp = Ibgp() + + as2 = base.createAutonomousSystem(2) + as2.setIbgpMode("full-mesh") + as2.setBgpScope("edge-only") + as2.createNetwork("west") + as2.createNetwork("east") + edge_west = as2.createRouter("edge_west").joinNetwork("west") + core = as2.createRouter("core").joinNetwork("west").joinNetwork("east") + edge_east = as2.createRouter("edge_east").joinNetwork("east") + edge_west.setBgpRole("edge") + core.setBgpRole("core") + edge_east.setBgpRole("edge") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.addLayer(ibgp) + emu.render() + + edge_west = emu.getRegistry().get("2", "rnode", "edge_west") + core = emu.getRegistry().get("2", "rnode", "core") + edge_east = emu.getRegistry().get("2", "rnode", "edge_east") + + west_sessions = [session for session in get_bgp_sessions(edge_west) if session["kind"] == "ibgp"] + east_sessions = [session for session in get_bgp_sessions(edge_east) if session["kind"] == "ibgp"] + assert edge_west.getBgpRole() == "edge" + assert edge_west.getLabel()["seedemu_bgp_role"] == "edge" + assert core.getBgpRole() == "core" + assert len(west_sessions) == 1 + assert len(east_sessions) == 1 + assert west_sessions[0]["peer_address"] == str(edge_east.getLoopbackAddress()) + assert east_sessions[0]["peer_address"] == str(edge_west.getLoopbackAddress()) + assert [session for session in get_bgp_sessions(core) if session["kind"] == "ibgp"] == [] + + +def test_router_bgp_roles_do_not_change_legacy_ibgp_default(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + ibgp = Ibgp() + + as2 = base.createAutonomousSystem(2) + as2.createNetwork("west") + as2.createNetwork("east") + as2.createRouter("edge_west").joinNetwork("west").setBgpRole("edge") + as2.createRouter("core").joinNetwork("west").joinNetwork("east").setBgpRole("core") + as2.createRouter("edge_east").joinNetwork("east").setBgpRole("edge") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.addLayer(ibgp) + emu.render() + + core = emu.getRegistry().get("2", "rnode", "core") + assert len([session for session in get_bgp_sessions(core) if session["kind"] == "ibgp"]) == 2 + + +def test_router_bgp_role_and_disable_validation(): + router = Base().createAutonomousSystem(2).createRouter("r1") + + with pytest.raises(AssertionError, match="unsupported BGP role"): + router.setBgpRole("rr") + with pytest.raises(AssertionError, match="unsupported router control-plane disable flag"): + router.disableControlPlane("ospf") + + router.disableControlPlane("ibgp") + assert router.isControlPlaneDisabled("ibgp") + assert router.getDisabledControlPlanes() == {"ibgp"} + assert router.getLabel()["seedemu_control_plane_disabled_ibgp"] == "true" + + +def test_router_ibgp_disable_removes_local_and_remote_sessions(): + edge_west, core, edge_east = _build_three_router_ibgp_emulator( + Ibgp(), + configure_as=lambda as2: as2.getRouter("core").disableControlPlane("ibgp"), + ) + + west_sessions = [session for session in get_bgp_sessions(edge_west) if session["kind"] == "ibgp"] + east_sessions = [session for session in get_bgp_sessions(edge_east) if session["kind"] == "ibgp"] + assert len(west_sessions) == 1 + assert len(east_sessions) == 1 + assert west_sessions[0]["peer_address"] == str(edge_east.getLoopbackAddress()) + assert east_sessions[0]["peer_address"] == str(edge_west.getLoopbackAddress()) + assert [session for session in get_bgp_sessions(core) if session["kind"] == "ibgp"] == [] + +def test_disabled_ibgp_mode_matches_as_masking_semantics(): + ibgp = Ibgp() + edge_west, core, edge_east = _build_three_router_ibgp_emulator( + ibgp, + configure_as=lambda as2: as2.setIbgpMode("disabled"), + ) + + assert get_bgp_sessions(edge_west) == [] + assert get_bgp_sessions(core) == [] + assert get_bgp_sessions(edge_east) == [] + +def test_route_reflector_mode_completes_default_cluster_membership(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + ibgp = Ibgp() + + as2 = base.createAutonomousSystem(2) + as2.setIbgpMode("route-reflector") + as2.createNetwork("net0") + as2.createBgpCluster("10.2.0.1") + as2.createRouter("rr").joinNetwork("net0").joinBgpCluster("10.2.0.1").makeRouteReflector() + as2.createRouter("client").joinNetwork("net0").joinBgpCluster("10.2.0.1") + as2.createRouter("core").joinNetwork("net0") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.addLayer(ibgp) + emu.render() + + rr = emu.getRegistry().get("2", "rnode", "rr") + client = emu.getRegistry().get("2", "rnode", "client") + core = emu.getRegistry().get("2", "rnode", "core") + + assert len([session for session in get_bgp_sessions(rr) if session["route_reflector_client"]]) == 2 + assert len([session for session in get_bgp_sessions(client) if session["kind"] == "ibgp"]) == 1 + assert len([session for session in get_bgp_sessions(core) if session["kind"] == "ibgp"]) == 1 + assert core.getBgpClusterId() == "10.2.0.1" + + +def test_edge_router_can_also_be_route_reflector(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + ibgp = Ibgp() + + as2 = base.createAutonomousSystem(2) + as2.setIbgpMode("route-reflector") + as2.createNetwork("net0") + as2.createBgpCluster("10.2.0.1") + rr = as2.createRouter("edge_rr").joinNetwork("net0").joinBgpCluster("10.2.0.1") + rr.setBgpRole("edge").makeRouteReflector() + as2.createRouter("client").joinNetwork("net0").joinBgpCluster("10.2.0.1") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.addLayer(ibgp) + emu.render() + + edge_rr = emu.getRegistry().get("2", "rnode", "edge_rr") + assert edge_rr.getBgpRole() == "edge" + assert edge_rr.isRouteReflector() + assert len([session for session in get_bgp_sessions(edge_rr) if session["route_reflector_client"]]) == 1 + + +def test_ospf_legacy_mode_keeps_local_networks_active_by_default(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + + as2 = base.createAutonomousSystem(2) + as2.createNetwork("transit") + as2.createNetwork("hostnet") + as2.createRouter("r1").joinNetwork("transit").joinNetwork("hostnet") + as2.createRouter("r2").joinNetwork("transit") + as2.createHost("host").joinNetwork("hostnet") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.render() + + r1 = emu.getRegistry().get("2", "rnode", "r1") + intents = get_ospf_interface_intents(r1) + assert "transit" in intents["active"] + assert "hostnet" in intents["active"] + + +def test_ospf_router_transit_only_mode_keeps_host_network_passive(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + + as2 = base.createAutonomousSystem(2) + as2.setOspfMode("router-transit-only") + as2.createNetwork("transit") + as2.createNetwork("hostnet") + as2.createNetwork("hostnet2") + as2.createRouter("r1").joinNetwork("transit").joinNetwork("hostnet") + as2.createRouter("r2", routingBackend="frr").joinNetwork("transit").joinNetwork("hostnet2") + as2.createHost("host").joinNetwork("hostnet") + as2.createHost("host2").joinNetwork("hostnet2") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.render() + + r1 = emu.getRegistry().get("2", "rnode", "r1") + intents = get_ospf_interface_intents(r1) + assert "transit" in intents["active"] + assert "hostnet" in intents["passive"] + assert "hostnet" not in intents["active"] + + bird_conf = _file_content(r1, "/etc/bird/bird.conf") + frr_conf = _file_content(emu.getRegistry().get("2", "rnode", "r2"), "/etc/frr/frr.conf") + assert 'interface "transit" { hello 1; dead count 2; }' in bird_conf + assert 'interface "hostnet" { stub; }' in bird_conf + assert "interface transit\n ip ospf area 0" in frr_conf + assert "interface hostnet2\n ip ospf area 0\n ip ospf passive" in frr_conf + + +def test_ospf_router_transit_only_respects_explicit_stub_and_mask(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf() + ospf.markAsStub(2, "stubbed") + ospf.maskNetwork(2, "masked") + + as2 = base.createAutonomousSystem(2) + as2.setOspfMode("router-transit-only") + as2.createNetwork("transit") + as2.createNetwork("stubbed") + as2.createNetwork("masked") + as2.createRouter("r1").joinNetwork("transit").joinNetwork("stubbed").joinNetwork("masked") + as2.createRouter("r2").joinNetwork("transit").joinNetwork("stubbed").joinNetwork("masked") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ospf) + emu.render() + + r1 = emu.getRegistry().get("2", "rnode", "r1") + intents = get_ospf_interface_intents(r1) + assert "transit" in intents["active"] + assert "stubbed" in intents["passive"] + assert "stubbed" not in intents["active"] + assert "masked" not in intents["active"] + assert "masked" not in intents["passive"] + + def test_duplicate_bgp_session_names_are_preserved_with_unique_render_names(): emu = Emulator() base = Base() @@ -176,6 +522,111 @@ def test_duplicate_bgp_session_names_are_preserved_with_unique_render_names(): assert bird_conf.count("protocol bgp p_as151") == 2 +def test_private_peering_can_select_explicit_ix_routers(): + emu = Emulator() + base = Base() + routing = Routing() + ebgp = Ebgp() + + base.createInternetExchange(100) + + as2 = base.createAutonomousSystem(2) + as2.createRouter("edge_west").joinNetwork("ix100", address="10.100.0.20") + as2.createRouter("edge_east").joinNetwork("ix100", address="10.100.0.21") + + as151 = base.createAutonomousSystem(151) + as151.createRouter("router0").joinNetwork("ix100", address="10.100.0.151") + + ebgp.addPrivatePeeringByRouters( + 100, + 2, + "edge_east", + 151, + "router0", + abRelationship=PeerRelationship.Provider, + ) + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ebgp) + emu.render() + + edge_west = emu.getRegistry().get("2", "rnode", "edge_west") + edge_east = emu.getRegistry().get("2", "rnode", "edge_east") + customer = emu.getRegistry().get("151", "rnode", "router0") + + assert [session for session in get_bgp_sessions(edge_west) if session["kind"] == "ebgp"] == [] + east_sessions = [session for session in get_bgp_sessions(edge_east) if session["kind"] == "ebgp"] + assert len(east_sessions) == 1 + assert east_sessions[0]["local_address"] == "10.100.0.21" + assert east_sessions[0]["peer_address"] == "10.100.0.151" + assert east_sessions[0]["peer_asn"] == 151 + + customer_conf = _file_content(customer, "/etc/bird/bird.conf") + assert "neighbor 10.100.0.21 as 2" in customer_conf + assert "neighbor 10.100.0.20 as 2" not in customer_conf + + +def test_route_server_peer_can_select_explicit_ix_router(): + emu = Emulator() + base = Base() + routing = Routing() + ebgp = Ebgp() + + base.createInternetExchange(100) + + as2 = base.createAutonomousSystem(2) + as2.createRouter("edge_west").joinNetwork("ix100", address="10.100.0.20") + as2.createRouter("edge_east").joinNetwork("ix100", address="10.100.0.21") + + ebgp.addRsPeerByRouter(100, 2, "edge_east") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ebgp) + emu.render() + + route_server = emu.getRegistry().get("ix", "rs", "ix100") + edge_west = emu.getRegistry().get("2", "rnode", "edge_west") + edge_east = emu.getRegistry().get("2", "rnode", "edge_east") + + assert [session for session in get_bgp_sessions(edge_west) if session["kind"] == "ebgp"] == [] + east_sessions = [session for session in get_bgp_sessions(edge_east) if session["kind"] == "ebgp"] + assert len(east_sessions) == 1 + assert east_sessions[0]["local_address"] == "10.100.0.21" + assert east_sessions[0]["peer_asn"] == 100 + + rs_conf = _file_content(route_server, "/etc/bird/bird.conf") + assert "neighbor 10.100.0.21 as 2" in rs_conf + assert "neighbor 10.100.0.20 as 2" not in rs_conf + + +def test_explicit_ix_router_selection_rejects_non_attached_router(): + emu = Emulator() + base = Base() + routing = Routing() + ebgp = Ebgp() + + base.createInternetExchange(100) + + as2 = base.createAutonomousSystem(2) + as2.createNetwork("net0") + as2.createRouter("edge").joinNetwork("ix100", address="10.100.0.20") + as2.createRouter("core").joinNetwork("net0") + + as151 = base.createAutonomousSystem(151) + as151.createRouter("router0").joinNetwork("ix100", address="10.100.0.151") + + ebgp.addPrivatePeeringByRouters(100, 2, "core", 151, "router0") + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ebgp) + + with pytest.raises(AssertionError, match="explicit peering router as2/core is not connected to ix100"): + emu.render() + + def test_route_reflector_intent_renders_without_direct_ibgp_bird_writes(): emu = Emulator() base = Base() @@ -238,7 +689,7 @@ def test_frr_route_reflector_renders_cluster_id_and_client(): assert "neighbor 10.0.0.2 passive" in frr_conf -def test_mpls_masks_ospf_and_ibgp_before_intent_is_recorded(): +def test_as_level_mpls_core_forwarding_masks_ospf_and_ibgp_before_intent_is_recorded(): emu = Emulator() base = Base() routing = Routing() @@ -258,7 +709,7 @@ def test_mpls_masks_ospf_and_ibgp_before_intent_is_recorded(): as2.createRouter("r2").joinNetwork("net0").joinNetwork("net1") as2.createRouter("r3").joinNetwork("net1").joinNetwork("net2") as2.createRouter("r4").joinNetwork("net2").joinNetwork("ix101") - mpls.enableOn(2) + as2.setCoreForwarding("mpls") as151 = base.createAutonomousSystem(151) as151.createNetwork("net0") @@ -284,7 +735,13 @@ def test_mpls_masks_ospf_and_ibgp_before_intent_is_recorded(): r4 = emu.getRegistry().get("2", "rnode", "r4") assert get_ospf_interface_intents(r1) == {"active": [], "passive": []} - assert [session for session in get_bgp_sessions(r1) if session["kind"] == "ibgp"] == [] + r1_ibgp = [session for session in get_bgp_sessions(r1) if session["kind"] == "ibgp"] + r4_ibgp = [session for session in get_bgp_sessions(r4) if session["kind"] == "ibgp"] + assert len(r1_ibgp) == 1 + assert len(r4_ibgp) == 1 + assert r1_ibgp[0]["name"] == "mpls_ibgp1" + assert r1_ibgp[0]["igp_table"] == "master4" + assert r1_ibgp[0]["peer_address"] == str(r4.getLoopbackAddress()) assert get_bgp_sessions(r2) == [] r1_conf = _file_content(r1, "/etc/bird/bird.conf") @@ -293,8 +750,61 @@ def test_mpls_masks_ospf_and_ibgp_before_intent_is_recorded(): assert "protocol ospf ospf1" not in r1_conf assert "protocol ospf ospf1" not in r2_conf assert "protocol bgp ibgp" not in r2_conf - assert "protocol bgp ibgp1" in r1_conf - assert "protocol bgp ibgp1" in r4_conf + assert "protocol bgp mpls_ibgp1" in r1_conf + assert "igp table master4" in r1_conf + assert "protocol bgp mpls_ibgp1" in r4_conf + + +def test_mpls_preserves_explicit_route_reflector_ibgp_mode(): + emu = Emulator() + base = Base() + routing = Routing() + ibgp = Ibgp() + ospf = Ospf() + mpls = Mpls() + + base.createInternetExchange(100) + base.createInternetExchange(101) + + as2 = base.createAutonomousSystem(2) + as2.setIbgpMode("route-reflector") + as2.setBgpScope("edge-only") + as2.createNetwork("net0") + as2.createNetwork("net1") + as2.createBgpCluster("10.2.0.1") + as2.createRouter("r1").joinNetwork("ix100").joinNetwork("net0").joinBgpCluster("10.2.0.1").setBgpRole("edge").makeRouteReflector() + as2.createRouter("r2").joinNetwork("net0").joinNetwork("net1").setBgpRole("core") + as2.createRouter("r4").joinNetwork("net1").joinNetwork("ix101").joinBgpCluster("10.2.0.1").setBgpRole("edge") + mpls.enableOn(2) + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ibgp) + emu.addLayer(ospf) + emu.addLayer(mpls) + emu.render() + + r1 = emu.getRegistry().get("2", "rnode", "r1") + r2 = emu.getRegistry().get("2", "rnode", "r2") + r4 = emu.getRegistry().get("2", "rnode", "r4") + + r1_ibgp = [session for session in get_bgp_sessions(r1) if session["kind"] == "ibgp"] + r4_ibgp = [session for session in get_bgp_sessions(r4) if session["kind"] == "ibgp"] + assert len(r1_ibgp) == 1 + assert r1_ibgp[0]["route_reflector_client"] + assert r1_ibgp[0]["igp_table"] == "master4" + assert len(r4_ibgp) == 1 + assert r4_ibgp[0]["name"] == "Ibgp_rr_r1" + assert get_bgp_sessions(r2) == [] + + r1_conf = _file_content(r1, "/etc/bird/bird.conf") + r2_conf = _file_content(r2, "/etc/bird/bird.conf") + r2_frr = _file_content(r2, "/etc/frr/frr.conf") + assert "protocol bgp Ibgp_rr_client_r4" in r1_conf + assert "igp table master4" in r1_conf + assert "protocol bgp mpls_ibgp" not in r1_conf + assert "protocol bgp" not in r2_conf + assert "mpls ldp" in r2_frr def test_exabgp_service_renders_speaker_and_router_peer():