diff --git a/examples/internet/b62_protocol_parama/README.md b/examples/internet/b62_protocol_parama/README.md new file mode 100644 index 000000000..e554d590c --- /dev/null +++ b/examples/internet/b62_protocol_parama/README.md @@ -0,0 +1,92 @@ +# Protocol Parameter Tuning + +This example is based on `B00_mini_internet`, but it keeps protocol timer +configuration explicit so users can see and adjust the new APIs. + +The topology is the same mini Internet shape as B00: transit ASes, route-server +peerings, private peerings, and stub ASes. The difference is in +`protocol_parama.py`: + +```python +ebgp = Ebgp().setTimers(holdTime=36000, keepaliveTime=60) +ibgp = Ibgp().setTimers(holdTime=36000, keepaliveTime=60) +ospf = Ospf().setTimers(tick=1, hello=1, dead=4) +``` + +## Protocol Parameters + +`Ospf().setTimers(tick=..., hello=..., dead=...)` + +- `tick`: BIRD OSPF scheduler tick interval, in seconds. +- `hello`: OSPF hello interval, in seconds. +- `dead`: OSPF dead interval, in seconds. +- Default values in SeedEMU are `tick=1`, `hello=1`, and `dead=4`. +- For BIRD, all three values are rendered. For FRR, `hello` and `dead` are + rendered because FRR does not expose a matching OSPF `tick` command. + +`Ebgp().setTimers(holdTime=..., keepaliveTime=...)` + +- `holdTime`: BGP hold time, in seconds. +- `keepaliveTime`: BGP keepalive interval, in seconds. +- Default values in SeedEMU are `holdTime=36000` and `keepaliveTime=60`. + +`Ibgp().setTimers(holdTime=..., keepaliveTime=...)` + +- Controls the same BGP hold and keepalive timers for iBGP sessions. +- Default values in SeedEMU are `holdTime=36000` and `keepaliveTime=60`. + +If these APIs are not called, SeedEMU uses the defaults above. Invalid timer +values, such as non-positive values or `keepaliveTime >= holdTime`, raise an +error during rendering. + +## Build And Run + +From the repository root: + +```sh +python3 seedemu/testing/cli.py clean examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +python3 seedemu/testing/cli.py compile examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +python3 seedemu/testing/cli.py build examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +python3 seedemu/testing/cli.py up examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +python3 seedemu/testing/cli.py probe examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +python3 seedemu/testing/cli.py test examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +python3 seedemu/testing/cli.py down examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +``` + +The full lifecycle can also be run with: + +```sh +python3 seedemu/testing/cli.py all examples/internet/b62_protocol_parama/example.yaml --artifact-dir ci-artifacts/b62 +``` + +## Failure And Recovery Control + +After the emulation is running, use `fault_control.py` to stop or recover one +or more services by Docker Compose service name. + +Stop multiple containers: + +```sh +python3 examples/internet/b62_protocol_parama/fault_control.py down --container brdnode_2_r100,brdnode_2_r101 +``` + +The same command also accepts space-separated names: + +```sh +python3 examples/internet/b62_protocol_parama/fault_control.py down --container brdnode_2_r100 brdnode_2_r101 +``` + +Recover them: + +```sh +python3 examples/internet/b62_protocol_parama/fault_control.py up --container brdnode_2_r100,brdnode_2_r101 +``` + +List generated Compose services: + +```sh +python3 examples/internet/b62_protocol_parama/fault_control.py list +``` + +By default, the script uses `output/docker-compose.yml` inside this example +directory. Use `--compose PATH` to point it at another Compose file. diff --git a/examples/internet/b62_protocol_parama/example.yaml b/examples/internet/b62_protocol_parama/example.yaml new file mode 100644 index 000000000..23ede45a2 --- /dev/null +++ b/examples/internet/b62_protocol_parama/example.yaml @@ -0,0 +1,81 @@ +id: internet-b62-protocol-parama +name: Protocol Parameter Tuning +description: Mini Internet topology that demonstrates OSPF and BGP timer APIs plus manual failure and recovery control. +runner: internet +script: protocol_parama.py +platform: amd +features: + - ipv4-default + - mini-internet + - protocol-parameters + - reconvergence + - ebgp-route-server + - ebgp-private-peering + - ibgp + - ospf + +compile: + enabled: true + output: output + clean: + - output + expected: + - output/docker-compose.yml + timeout: 600 + +build: + enabled: true + timeout: 1800 + +runtime: + compose: output/docker-compose.yml + readiness: + - name: representative B62 services are running + type: compose-ps + services: + - brdnode_2_r100 + - brdnode_2_r101 + - brdnode_3_r103 + - brdnode_4_r104 + - brdnode_11_r102 + - brdnode_12_r101 + - brdnode_150_router0 + - brdnode_152_router0 + - brdnode_160_router0 + - brdnode_171_router0 + - hnode_150_host_0 + - hnode_152_host_0 + - hnode_160_host_0 + - hnode_171_host_0 + retries: 40 + interval: 3 + +probes: + - name: AS150 reaches AS152 across tier-1 and tier-2 transit + type: ping + service: hnode_150_host_0 + target: 10.152.0.71 + count: 3 + retries: 40 + interval: 5 + + - name: AS150 reaches AS160 through AS3 + type: ping + service: hnode_150_host_0 + target: 10.160.0.71 + count: 3 + retries: 40 + interval: 5 + + - name: AS171 reaches AS154 customized host + type: ping + service: hnode_171_host_0 + target: 10.154.0.129 + count: 3 + retries: 40 + interval: 5 + +test_programs: + - name: B62 custom runtime validation + script: test_runtime.py + timeout: 240 diff --git a/examples/internet/b62_protocol_parama/fault_control.py b/examples/internet/b62_protocol_parama/fault_control.py new file mode 100755 index 000000000..1560eb3ba --- /dev/null +++ b/examples/internet/b62_protocol_parama/fault_control.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path +from typing import Iterable, List, Sequence + +import yaml + + +SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_COMPOSE_FILE = SCRIPT_DIR / "output" / "docker-compose.yml" +SEED_ASN_LABEL = "org.seedsecuritylabs.seedemu.meta.asn" + + +def docker_compose_command() -> List[str]: + try: + result = subprocess.run( + ["docker", "compose", "version"], + text=True, + capture_output=True, + check=False, + ) + if result.returncode == 0: + return ["docker", "compose"] + except FileNotFoundError: + pass + return ["docker-compose"] + + +def parse_container_names(values: Iterable[str]) -> List[str]: + names: List[str] = [] + for value in values: + for item in str(value).split(","): + item = item.strip() + if item: + names.append(item) + return names + + +def load_services(compose_file: Path) -> List[str]: + with compose_file.open("r", encoding="utf-8") as handle: + compose = yaml.safe_load(handle) or {} + names: List[str] = [] + for name, service in compose.get("services", {}).items(): + labels = service.get("labels", {}) or {} + if SEED_ASN_LABEL in labels: + names.append(str(name)) + return sorted(names) + + +def validate_services(compose_file: Path, containers: Sequence[str]) -> None: + services = set(load_services(compose_file)) + missing = [name for name in containers if name not in services] + if missing: + raise SystemExit( + "unknown compose service(s): {}. Run `list` to inspect valid names.".format( + ", ".join(missing) + ) + ) + + +def run_compose(compose_file: Path, args: Sequence[str]) -> int: + cmd = docker_compose_command() + ["-f", str(compose_file)] + list(args) + print("running: {}".format(" ".join(cmd))) + result = subprocess.run( + cmd, + cwd=str(compose_file.parent), + text=True, + check=False, + ) + return int(result.returncode) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Stop or recover B62 protocol-parameter example containers." + ) + parser.add_argument("action", choices=["down", "up", "list", "status"]) + parser.add_argument( + "--compose", + type=Path, + default=DEFAULT_COMPOSE_FILE, + help="Path to docker-compose.yml. Defaults to this example's output directory.", + ) + parser.add_argument( + "--container", + nargs="*", + default=[], + help="One or more Compose services. Comma-separated and space-separated forms are both supported.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + compose_file = args.compose.resolve() + if not compose_file.exists(): + raise SystemExit("compose file not found: {}".format(compose_file)) + + if args.action == "list": + for service in load_services(compose_file): + print(service) + return 0 + + containers = parse_container_names(args.container) + if not containers: + raise SystemExit("--container is required for `{}`".format(args.action)) + validate_services(compose_file, containers) + + if args.action == "down": + return run_compose(compose_file, ["stop"] + containers) + if args.action == "up": + return run_compose(compose_file, ["up", "-d", "--no-deps"] + containers) + return run_compose(compose_file, ["ps"] + containers) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/examples/internet/b62_protocol_parama/protocol_parama.py b/examples/internet/b62_protocol_parama/protocol_parama.py new file mode 100755 index 000000000..c8f9ca016 --- /dev/null +++ b/examples/internet/b62_protocol_parama/protocol_parama.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from seedemu.compiler import Docker, Platform +from seedemu.core import Emulator +from seedemu.layers import Base, Ebgp, Ibgp, Ospf, PeerRelationship, Routing +from seedemu.utilities import Makers + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build the B62 protocol parameter example.") + parser.add_argument("legacy_platform", nargs="?", choices=["amd", "arm"]) + parser.add_argument("--platform", choices=["amd", "arm"]) + parser.add_argument("--output", default=str(SCRIPT_DIR / "output")) + parser.add_argument("--dumpfile") + parser.add_argument("--hosts-per-as", type=int, default=2) + parser.add_argument("--override", dest="override", action="store_true", default=True) + parser.add_argument("--no-override", dest="override", action="store_false") + parser.add_argument("--skip-render", dest="render", action="store_false", default=True) + args = parser.parse_args() + args.platform = args.platform or args.legacy_platform or "amd" + return args + + +def resolve_platform(name: str) -> Platform: + return Platform.AMD64 if name == "amd" else Platform.ARM64 + + +def build_emulator(hosts_per_as=2) -> Emulator: + emu = Emulator() + # Explicit protocol parameters. These currently match the SeedEMU defaults, + # but this example keeps the calls visible so users can tune convergence. + ebgp = Ebgp().setTimers(holdTime=36000, keepaliveTime=60) + ibgp = Ibgp().setTimers(holdTime=36000, keepaliveTime=60) + ospf = Ospf().setTimers(tick=1, hello=1, dead=4) + base = Base() + + ############################################################################### + # Create internet exchanges + ix100 = base.createInternetExchange(100) + ix101 = base.createInternetExchange(101) + ix102 = base.createInternetExchange(102) + ix103 = base.createInternetExchange(103) + ix104 = base.createInternetExchange(104) + ix105 = base.createInternetExchange(105) + + # Customize names (for visualization purpose) + ix100.getPeeringLan().setDisplayName("NYC-100") + ix101.getPeeringLan().setDisplayName("San Jose-101") + ix102.getPeeringLan().setDisplayName("Chicago-102") + ix103.getPeeringLan().setDisplayName("Miami-103") + ix104.getPeeringLan().setDisplayName("Boston-104") + ix105.getPeeringLan().setDisplayName("Huston-105") + + ############################################################################### + # Create Transit Autonomous Systems + + ## Tier 1 ASes + Makers.makeTransitAs( + base, + 2, + [100, 101, 102, 105], + [(100, 101), (101, 102), (100, 105)], + ) + + Makers.makeTransitAs( + base, + 3, + [100, 103, 104, 105], + [(100, 103), (100, 105), (103, 105), (103, 104)], + ) + + Makers.makeTransitAs( + base, + 4, + [100, 102, 104], + [(100, 104), (102, 104)], + ) + + ## Tier 2 ASes + Makers.makeTransitAs(base, 11, [102, 105], [(102, 105)]) + Makers.makeTransitAs(base, 12, [101, 104], [(101, 104)]) + + ############################################################################### + # Create single-homed stub ASes. + Makers.makeStubAsWithHosts(emu, base, 150, 100, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 151, 100, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 152, 101, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 153, 101, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 154, 102, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 160, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 161, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 162, 103, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 163, 104, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 164, 104, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 170, 105, hosts_per_as) + Makers.makeStubAsWithHosts(emu, base, 171, 105, hosts_per_as) + + # An example to show how to add a host with customized IP address. + as154 = base.getAutonomousSystem(154) + new_host = as154.createHost("host_new").joinNetwork("net0", address="10.154.0.129") + from seedemu.core import OptionMode, OptionRegistry + + o = OptionRegistry().sysctl_netipv4_conf_rp_filter( + {"all": False, "default": False, "net0": False}, + mode=OptionMode.RUN_TIME, + ) + new_host.setOption(o) + + o = OptionRegistry().sysctl_netipv4_udp_rmem_min(5000, mode=OptionMode.RUN_TIME) + new_host.setOption(o) + + ############################################################################### + # Peering via route servers. + ebgp.addRsPeers(100, [2, 3, 4]) + ebgp.addRsPeers(102, [2, 4]) + ebgp.addRsPeers(104, [3, 4]) + ebgp.addRsPeers(105, [2, 3]) + + # Private peerings for transit service. + ebgp.addPrivatePeerings(100, [2], [150, 151], PeerRelationship.Provider) + ebgp.addPrivatePeerings(100, [3], [150], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(101, [2], [12], PeerRelationship.Provider) + ebgp.addPrivatePeerings(101, [12], [152, 153], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(102, [2, 4], [11, 154], PeerRelationship.Provider) + ebgp.addPrivatePeerings(102, [11], [154], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(103, [3], [160, 161, 162], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(104, [3, 4], [12], PeerRelationship.Provider) + ebgp.addPrivatePeerings(104, [4], [163], PeerRelationship.Provider) + ebgp.addPrivatePeerings(104, [12], [164], PeerRelationship.Provider) + + ebgp.addPrivatePeerings(105, [3], [11, 170], PeerRelationship.Provider) + ebgp.addPrivatePeerings(105, [11], [171], PeerRelationship.Provider) + + ############################################################################### + # Add layers to the emulator + + emu.addLayer(base) + emu.addLayer(Routing()) + emu.addLayer(ebgp) + emu.addLayer(ibgp) + emu.addLayer(ospf) + return emu + + +def run( + dumpfile=None, + hosts_per_as=2, + output=None, + platform=Platform.AMD64, + override=True, + render=True, +): + emu = build_emulator(hosts_per_as=hosts_per_as) + if dumpfile is not None: + # Save it to a file, so it can be used by other emulators. + emu.dump(dumpfile) + return + + if render: + emu.render() + + docker = Docker(platform=platform) + emu.compile(docker, output or "./output", override=override) + + +def main() -> int: + args = parse_args() + output_dir = Path(args.output).resolve() + output_dir.parent.mkdir(parents=True, exist_ok=True) + run( + dumpfile=args.dumpfile, + hosts_per_as=args.hosts_per_as, + output=str(output_dir), + platform=resolve_platform(args.platform), + override=args.override, + render=args.render, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/internet/b62_protocol_parama/test_runtime.py b/examples/internet/b62_protocol_parama/test_runtime.py new file mode 100644 index 000000000..7c365c621 --- /dev/null +++ b/examples/internet/b62_protocol_parama/test_runtime.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from seedemu.testing import ComposeRuntimeTest + + +def main() -> int: + test = ComposeRuntimeTest(__file__) + + host150 = test.require_service(150, "host_0") + host152 = test.require_service(152, "host_0") + host160 = test.require_service(160, "host_0") + host171 = test.require_service(171, "host_0") + host154_new = test.require_service(154, "host_new") + + if host150 and host152: + test.exec_check("AS150 reaches AS152 across the mini Internet", host150, "ping -c 3 {} >/dev/null".format(host152.address)) + if host150 and host160: + test.exec_check("AS150 reaches AS160 through AS3", host150, "ping -c 3 {} >/dev/null".format(host160.address)) + if host171 and host154_new: + test.exec_check("AS171 reaches AS154 customized host", host171, "ping -c 3 {} >/dev/null".format(host154_new.address)) + if host154_new: + test.exec_check( + "AS154 customized host has the expected address", + host154_new, + "ip addr show net0 | grep -q '10.154.0.129'", + ) + + test.write_summary("b62-protocol-parama-runtime-test.json") + return test.exit_code() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/seedemu/layers/Ebgp.py b/seedemu/layers/Ebgp.py index b63425936..14fab817c 100644 --- a/seedemu/layers/Ebgp.py +++ b/seedemu/layers/Ebgp.py @@ -9,7 +9,10 @@ BGP_COMMUNITY_PROVIDER, BGP_EXPORT_ALL, BGP_EXPORT_LOCAL_AND_CUSTOMER, + DEFAULT_BGP_HOLD_TIME, + DEFAULT_BGP_KEEPALIVE_TIME, install_router_bgp_session, + normalize_bgp_timers, ) EbgpFileTemplates: Dict[str, str] = {} @@ -41,6 +44,8 @@ class Ebgp(Layer, Graphable): __rs_peers: List[Tuple[int, int]] __rs_peer_routers: Dict[Tuple[int, int], str] __xc_peerings: Dict[Tuple[int, int], PeerRelationship] + __hold_time: int + __keepalive_time: int def __init__(self): """! @@ -52,8 +57,42 @@ def __init__(self): self.__xc_peerings = {} self.__rs_peers = [] self.__rs_peer_routers = {} + self.__hold_time = DEFAULT_BGP_HOLD_TIME + self.__keepalive_time = DEFAULT_BGP_KEEPALIVE_TIME self.addDependency('Routing', False, False) + def setTimers( + self, + holdTime: Optional[int] = None, + keepaliveTime: Optional[int] = None, + ) -> Ebgp: + """! + @brief Set default eBGP hold and keepalive timers. + + @param holdTime BGP hold time, in seconds. + @param keepaliveTime BGP keepalive time, in seconds. + + @returns self, for chaining API calls. + """ + timers = normalize_bgp_timers( + self.__hold_time if holdTime is None else holdTime, + self.__keepalive_time if keepaliveTime is None else keepaliveTime, + ) + self.__hold_time = timers["hold_time"] + self.__keepalive_time = timers["keepalive_time"] + return self + + def getTimers(self) -> Dict[str, int]: + """! + @brief Get eBGP timer defaults used by this layer. + + @returns dict with hold_time and keepalive_time. + """ + return { + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, + } + def __recordPeer( self, node: Router, @@ -82,6 +121,8 @@ def __recordPeer( "export_policy": exportPolicy, "next_hop_self": nextHopSelf, "route_server_client": routeServerClient, + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, }, ) diff --git a/seedemu/layers/Ibgp.py b/seedemu/layers/Ibgp.py index ef75ac14a..db4d39d38 100644 --- a/seedemu/layers/Ibgp.py +++ b/seedemu/layers/Ibgp.py @@ -2,8 +2,13 @@ from seedemu.core.enums import NetworkType, NodeRole from .Base import Base from seedemu.core import ScopedRegistry, Node, Graphable, Emulator, Layer -from typing import Dict, List, Set, Tuple -from ._bgp_metadata import install_router_bgp_session +from typing import Dict, List, Optional, Set, Tuple +from ._bgp_metadata import ( + DEFAULT_BGP_HOLD_TIME, + DEFAULT_BGP_KEEPALIVE_TIME, + install_router_bgp_session, + normalize_bgp_timers, +) IBGP_MODE_FULL_MESH = "full-mesh" IBGP_MODE_ROUTE_REFLECTOR = "route-reflector" @@ -24,6 +29,8 @@ class Ibgp(Layer, Graphable): iBGP sessions between routers within each AS. """ __masked: Set[int] + __hold_time: int + __keepalive_time: int def __init__(self): """! @@ -31,6 +38,8 @@ def __init__(self): """ super().__init__() self.__masked = set() + self.__hold_time = DEFAULT_BGP_HOLD_TIME + self.__keepalive_time = DEFAULT_BGP_KEEPALIVE_TIME self.addDependency('Ospf', False, False) def __dfs(self, start: Node, visited: List[Node], netname: str = 'self'): @@ -66,6 +75,38 @@ def __dfs(self, start: Node, visited: List[Node], netname: str = 'self'): def getName(self) -> str: return 'Ibgp' + def setTimers( + self, + holdTime: Optional[int] = None, + keepaliveTime: Optional[int] = None, + ) -> Ibgp: + """! + @brief Set default iBGP hold and keepalive timers. + + @param holdTime BGP hold time, in seconds. + @param keepaliveTime BGP keepalive time, in seconds. + + @returns self, for chaining API calls. + """ + timers = normalize_bgp_timers( + self.__hold_time if holdTime is None else holdTime, + self.__keepalive_time if keepaliveTime is None else keepaliveTime, + ) + self.__hold_time = timers["hold_time"] + self.__keepalive_time = timers["keepalive_time"] + return self + + def getTimers(self) -> Dict[str, int]: + """! + @brief Get iBGP timer defaults used by this layer. + + @returns dict with hold_time and keepalive_time. + """ + return { + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, + } + def maskAsn(self, asn: int) -> Ibgp: """! @brief Mask an AS. @@ -178,6 +219,8 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "passive": True, "route_reflector_client": True, "route_reflector_cluster_id": cluster_id, + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, }, ) install_router_bgp_session( @@ -192,6 +235,8 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "export_policy": "all", "next_hop_self": True, "igp_table": igp_table, + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, }, ) @@ -223,6 +268,8 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "export_policy": "all", "next_hop_self": False, "igp_table": igp_table, + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, }, ) install_router_bgp_session( @@ -237,6 +284,8 @@ def _render_rr_mode(self, asn: int, clusters: Dict[str, Tuple[Set[str], Set[str] "export_policy": "all", "next_hop_self": False, "igp_table": igp_table, + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, }, ) @@ -283,6 +332,8 @@ def _render_full_mesh_mode(self, asn: int, routers: List[Node], igp_table: str = "export_policy": "all", "next_hop_self": False, "igp_table": igp_table, + "hold_time": self.__hold_time, + "keepalive_time": self.__keepalive_time, }, ) diff --git a/seedemu/layers/Ospf.py b/seedemu/layers/Ospf.py index 50051096c..f57ecead2 100644 --- a/seedemu/layers/Ospf.py +++ b/seedemu/layers/Ospf.py @@ -1,9 +1,16 @@ from __future__ import annotations from seedemu.core import Node, Emulator, Layer from seedemu.core.enums import NetworkType, NodeRole -from typing import Set, Dict, List, Tuple +from typing import Set, Dict, List, Tuple, Optional from .Base import Base -from ._bgp_metadata import classify_ospf_interfaces, set_ospf_interface_intents +from ._bgp_metadata import ( + DEFAULT_OSPF_DEAD, + DEFAULT_OSPF_HELLO, + DEFAULT_OSPF_TICK, + classify_ospf_interfaces, + normalize_ospf_timers, + set_ospf_interface_intents, +) OspfFileTemplates: Dict[str, str] = {} @@ -26,6 +33,9 @@ class Ospf(Layer): __stubs: Set[Tuple[int, str]] __masked: Set[Tuple[int, str]] __masked_asn: Set[int] + __tick: int + __hello: int + __dead: int def __init__(self): """! @@ -35,12 +45,52 @@ def __init__(self): self.__stubs = set() self.__masked = set() self.__masked_asn = set() + self.__tick = DEFAULT_OSPF_TICK + self.__hello = DEFAULT_OSPF_HELLO + self.__dead = DEFAULT_OSPF_DEAD self.addDependency('Routing', False, False) def getName(self) -> str: return 'Ospf' + def setTimers( + self, + tick: Optional[int] = None, + hello: Optional[int] = None, + dead: Optional[int] = None, + ) -> Ospf: + """! + @brief Set default OSPF timers for generated OSPF sessions. + + @param tick BIRD OSPF scheduler tick interval, in seconds. + @param hello OSPF hello interval, in seconds. + @param dead OSPF dead interval, in seconds. + + @returns self, for chaining API calls. + """ + timers = normalize_ospf_timers( + self.__tick if tick is None else tick, + self.__hello if hello is None else hello, + self.__dead if dead is None else dead, + ) + self.__tick = timers["tick"] + self.__hello = timers["hello"] + self.__dead = timers["dead"] + return self + + def getTimers(self) -> Dict[str, int]: + """! + @brief Get OSPF timer defaults used by this layer. + + @returns dict with tick, hello, and dead timer values. + """ + return { + "tick": self.__tick, + "hello": self.__hello, + "dead": self.__dead, + } + def markAsStub(self, asn: int, netname: str) -> Ospf: """! @brief Set all OSPF interfaces connected to a network as stub @@ -193,7 +243,14 @@ def configure(self, emulator: Emulator): stubs=stub_networks, masked=masked_networks, ) - set_ospf_interface_intents(router, active, stubs) + set_ospf_interface_intents( + router, + active, + stubs, + tick=self.__tick, + hello=self.__hello, + dead=self.__dead, + ) def render(self, emulator: Emulator): pass diff --git a/seedemu/layers/Routing.py b/seedemu/layers/Routing.py index f46a7f065..4a51d973f 100644 --- a/seedemu/layers/Routing.py +++ b/seedemu/layers/Routing.py @@ -11,6 +11,7 @@ get_bgp_backend, get_bgp_sessions, get_ospf_interface_intents, + get_ospf_timers, has_bgp_connected_export, ensure_bird_bgp_base, render_frr_community, @@ -58,13 +59,14 @@ import all; export all; }}; + tick {tick}; area 0 {{ {interfaces} }}; """ RoutingFileTemplates['bird_ospf_interface'] = """\ - interface "{interfaceName}" {{ hello 1; dead count 2; }}; + interface "{interfaceName}" {{ hello {hello}; dead {dead}; }}; """ RoutingFileTemplates['bird_ospf_stub_interface'] = """\ @@ -136,8 +138,8 @@ FrrFileTemplates["ospf_interface_active"] = """\ interface {interface} ip ospf area 0 - ip ospf hello-interval 1 - ip ospf dead-interval 2 + ip ospf hello-interval {hello} + ip ospf dead-interval {dead} ! """ @@ -268,16 +270,22 @@ def _render_bird_ospf(self, rnode: Router): if not intents["active"] and not intents["passive"]: return + timers = get_ospf_timers(rnode) ospf_interfaces = '' for name in intents["passive"]: ospf_interfaces += RoutingFileTemplates['bird_ospf_stub_interface'].format(interfaceName=name) for name in intents["active"]: - ospf_interfaces += RoutingFileTemplates['bird_ospf_interface'].format(interfaceName=name) + ospf_interfaces += RoutingFileTemplates['bird_ospf_interface'].format( + interfaceName=name, + hello=timers["hello"], + dead=timers["dead"], + ) if ospf_interfaces != '': rnode.addTable('t_ospf') rnode.addProtocol('ospf', 'ospf1', RoutingFileTemplates['bird_ospf_body'].format( - interfaces=ospf_interfaces + interfaces=ospf_interfaces, + tick=timers["tick"], )) rnode.addTablePipe('t_ospf') @@ -380,6 +388,13 @@ def _render_frr_bgp(self, router: Router) -> str: bgp.append(" neighbor {} remote-as {}".format(session["peer_address"], session["peer_asn"])) bgp.append(" neighbor {} update-source {}".format(session["peer_address"], session["local_address"])) bgp.append(" neighbor {} description {}".format(session["peer_address"], session["name"])) + bgp.append( + " neighbor {} timers {} {}".format( + session["peer_address"], + session["keepalive_time"], + session["hold_time"], + ) + ) if session["passive"]: bgp.append(" neighbor {} passive".format(session["peer_address"])) bgp.append(" !") @@ -418,11 +433,18 @@ def _render_frr_ospf(self, router: Router) -> str: if not intents["active"] and not intents["passive"]: return "" + timers = get_ospf_timers(router) body: List[str] = [] for name in intents["passive"]: body.append(FrrFileTemplates["ospf_interface_passive"].format(interface=name)) for name in intents["active"]: - body.append(FrrFileTemplates["ospf_interface_active"].format(interface=name)) + body.append( + FrrFileTemplates["ospf_interface_active"].format( + interface=name, + hello=timers["hello"], + dead=timers["dead"], + ) + ) body.append(FrrFileTemplates["ospf_router"].format(router_id=router.getLoopbackAddress())) return "".join(body) diff --git a/seedemu/layers/_bgp_metadata.py b/seedemu/layers/_bgp_metadata.py index 3d5b31aed..ca63e8962 100644 --- a/seedemu/layers/_bgp_metadata.py +++ b/seedemu/layers/_bgp_metadata.py @@ -13,6 +13,12 @@ BGP_BOOTSTRAPPED_ATTR = "__bgp_bootstrapped" OSPF_INTERFACE_INTENTS_ATTR = "__ospf_interface_intents" +DEFAULT_OSPF_TICK = 1 +DEFAULT_OSPF_HELLO = 1 +DEFAULT_OSPF_DEAD = 4 +DEFAULT_BGP_HOLD_TIME = 36000 +DEFAULT_BGP_KEEPALIVE_TIME = 60 + BGP_BACKEND_BIRD = "bird" BGP_BACKEND_FRR = "frr" @@ -59,6 +65,8 @@ """ BIRD_RS_PEER_TEMPLATE = """\ + hold time {holdTime}; + keepalive time {keepaliveTime}; ipv4 {{ import all; export all; @@ -69,6 +77,8 @@ """ BIRD_ROUTER_PEER_TEMPLATE = """\ + hold time {holdTime}; + keepalive time {keepaliveTime}; ipv4 {{ table t_bgp; import {importClause}; @@ -80,6 +90,8 @@ BIRD_IBGP_PEER_TEMPLATE = """\ {passive}\ + hold time {holdTime}; + keepalive time {keepaliveTime}; ipv4 {{ table t_bgp; import all; @@ -125,6 +137,44 @@ def _normalize_community(community: Any) -> Optional[str]: return BGP_COMMUNITY_ALIASES[value] +def _positive_int(value: Any, field: str) -> int: + normalized = int(value) + if normalized <= 0: + raise ValueError("{} must be a positive integer".format(field)) + return normalized + + +def normalize_bgp_timers( + hold_time: Any = DEFAULT_BGP_HOLD_TIME, + keepalive_time: Any = DEFAULT_BGP_KEEPALIVE_TIME, +) -> Dict[str, int]: + hold = _positive_int(hold_time, "BGP hold_time") + keepalive = _positive_int(keepalive_time, "BGP keepalive_time") + if keepalive >= hold: + raise ValueError("BGP keepalive_time must be smaller than hold_time") + return { + "hold_time": hold, + "keepalive_time": keepalive, + } + + +def normalize_ospf_timers( + tick: Any = DEFAULT_OSPF_TICK, + hello: Any = DEFAULT_OSPF_HELLO, + dead: Any = DEFAULT_OSPF_DEAD, +) -> Dict[str, int]: + normalized_tick = _positive_int(tick, "OSPF tick") + normalized_hello = _positive_int(hello, "OSPF hello") + normalized_dead = _positive_int(dead, "OSPF dead") + if normalized_dead <= normalized_hello: + raise ValueError("OSPF dead must be larger than hello") + return { + "tick": normalized_tick, + "hello": normalized_hello, + "dead": normalized_dead, + } + + def render_bird_community(community: str) -> str: normalized = _normalize_community(community) if normalized is None: @@ -215,6 +265,10 @@ def normalize_bgp_session(session: Dict[str, Any]) -> Dict[str, Any]: import_community = _normalize_community(session.get("import_community")) local_pref_value = session.get("local_pref") local_pref = int(local_pref_value) if local_pref_value not in {None, ""} else None + timers = normalize_bgp_timers( + session.get("hold_time", DEFAULT_BGP_HOLD_TIME), + session.get("keepalive_time", DEFAULT_BGP_KEEPALIVE_TIME), + ) return { "name": name, @@ -232,6 +286,8 @@ def normalize_bgp_session(session: Dict[str, Any]) -> Dict[str, Any]: "route_reflector_cluster_id": route_reflector_cluster_id, "passive": bool(session.get("passive", False)), "igp_table": str(session.get("igp_table") or "t_ospf").strip() or "t_ospf", + "hold_time": timers["hold_time"], + "keepalive_time": timers["keepalive_time"], } @@ -318,6 +374,8 @@ def render_bird_protocol_body(session: Dict[str, Any]) -> str: localAsn=normalized["local_asn"], peerAddress=normalized["peer_address"], peerAsn=normalized["peer_asn"], + holdTime=normalized["hold_time"], + keepaliveTime=normalized["keepalive_time"], ) if normalized["kind"] == BGP_KIND_IBGP: return BIRD_IBGP_PEER_TEMPLATE.format( @@ -325,6 +383,8 @@ def render_bird_protocol_body(session: Dict[str, Any]) -> str: localAsn=normalized["local_asn"], peerAddress=normalized["peer_address"], peerAsn=normalized["peer_asn"], + holdTime=normalized["hold_time"], + keepaliveTime=normalized["keepalive_time"], igpTable=normalized["igp_table"], nextHopSelf=" next hop self;\n" if normalized["next_hop_self"] else "", passive=" passive yes;\n" if normalized["passive"] else "", @@ -340,6 +400,8 @@ def render_bird_protocol_body(session: Dict[str, Any]) -> str: localAsn=normalized["local_asn"], peerAddress=normalized["peer_address"], peerAsn=normalized["peer_asn"], + holdTime=normalized["hold_time"], + keepaliveTime=normalized["keepalive_time"], importClause=_bird_import_clause(normalized), exportClause=_bird_export_clause(normalized), nextHopSelf=" next hop self;\n" if normalized["next_hop_self"] else "", @@ -375,12 +437,22 @@ def classify_ospf_interfaces( return active, passive -def set_ospf_interface_intents(node: Router, active: Iterable[str], passive: Iterable[str]) -> None: +def set_ospf_interface_intents( + node: Router, + active: Iterable[str], + passive: Iterable[str], + *, + tick: int = DEFAULT_OSPF_TICK, + hello: int = DEFAULT_OSPF_HELLO, + dead: int = DEFAULT_OSPF_DEAD, +) -> None: + timers = normalize_ospf_timers(tick, hello, dead) node.setAttribute( OSPF_INTERFACE_INTENTS_ATTR, { "active": sorted({str(name) for name in active}), "passive": sorted({str(name) for name in passive}), + "timers": timers, }, ) @@ -391,3 +463,13 @@ def get_ospf_interface_intents(node: Router) -> Dict[str, List[str]]: "active": [str(name) for name in list(raw.get("active", []) or [])], "passive": [str(name) for name in list(raw.get("passive", []) or [])], } + + +def get_ospf_timers(node: Router) -> Dict[str, int]: + raw = node.getAttribute(OSPF_INTERFACE_INTENTS_ATTR, {}) or {} + timers = raw.get("timers", {}) if isinstance(raw, dict) else {} + return normalize_ospf_timers( + timers.get("tick", DEFAULT_OSPF_TICK), + timers.get("hello", DEFAULT_OSPF_HELLO), + timers.get("dead", DEFAULT_OSPF_DEAD), + ) diff --git a/seedemu/testing/base.py b/seedemu/testing/base.py index c814e7420..84c0e7dd3 100644 --- a/seedemu/testing/base.py +++ b/seedemu/testing/base.py @@ -533,6 +533,15 @@ def write_json(self, name: str, data: Dict[str, Any]) -> None: def test_env(self, extra: Dict[str, Any]) -> Dict[str, str]: env = self.docker_env() + repo_root = str(Path(__file__).resolve().parents[2]) + pythonpath = [ + item + for item in str(env.get("PYTHONPATH", "")).split(os.pathsep) + if item + ] + if repo_root not in pythonpath: + pythonpath.insert(0, repo_root) + env["PYTHONPATH"] = os.pathsep.join(pythonpath) env.update( { "TEST_RUNNER_NAME": self.runner_name, diff --git a/test_frr_exabgp_foundation.py b/test_frr_exabgp_foundation.py index 06e04db18..831857248 100644 --- a/test_frr_exabgp_foundation.py +++ b/test_frr_exabgp_foundation.py @@ -6,7 +6,7 @@ from seedemu.core import Binding, Emulator, Filter from seedemu.layers import Base, Ebgp, Ibgp, Mpls, Ospf, PeerRelationship, Routing -from seedemu.layers._bgp_metadata import get_bgp_sessions, get_ospf_interface_intents +from seedemu.layers._bgp_metadata import get_bgp_sessions, get_ospf_interface_intents, get_ospf_timers from seedemu.services import ExaBgpService @@ -452,12 +452,72 @@ def test_ospf_router_transit_only_mode_keeps_host_network_passive(): 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 "tick 1;" in bird_conf + assert 'interface "transit" { hello 1; dead 4; }' in bird_conf assert 'interface "hostnet" { stub; }' in bird_conf - assert "interface transit\n ip ospf area 0" in frr_conf + assert "interface transit\n ip ospf area 0\n ip ospf hello-interval 1\n ip ospf dead-interval 4" in frr_conf assert "interface hostnet2\n ip ospf area 0\n ip ospf passive" in frr_conf +def test_protocol_timer_api_records_intent_and_renders_to_bird_and_frr(): + emu = Emulator() + base = Base() + routing = Routing() + ospf = Ospf().setTimers(tick=7, hello=11, dead=44) + ibgp = Ibgp().setTimers(holdTime=5400, keepaliveTime=90) + ebgp = Ebgp().setTimers(holdTime=7200, keepaliveTime=120) + + base.createInternetExchange(100) + base.createInternetExchange(101) + + as2 = base.createAutonomousSystem(2) + as2.createNetwork("net0") + as2.createRouter("r1").joinNetwork("net0").joinNetwork("ix100") + as2.createRouter("r2", routingBackend="frr").joinNetwork("net0").joinNetwork("ix101") + + as151 = base.createAutonomousSystem(151) + as151.createRouter("router0").joinNetwork("ix100") + + as152 = base.createAutonomousSystem(152) + as152.createRouter("router0").joinNetwork("ix101") + + ebgp.addPrivatePeering(100, 2, 151, abRelationship=PeerRelationship.Provider) + ebgp.addPrivatePeering(101, 2, 152, abRelationship=PeerRelationship.Provider) + + emu.addLayer(base) + emu.addLayer(routing) + emu.addLayer(ebgp) + emu.addLayer(ibgp) + emu.addLayer(ospf) + emu.render() + + r1 = emu.getRegistry().get("2", "rnode", "r1") + r2 = emu.getRegistry().get("2", "rnode", "r2") + r1_conf = _file_content(r1, "/etc/bird/bird.conf") + r2_conf = _file_content(r2, "/etc/frr/frr.conf") + r1_sessions = get_bgp_sessions(r1) + + assert ospf.getTimers() == {"tick": 7, "hello": 11, "dead": 44} + assert ibgp.getTimers() == {"hold_time": 5400, "keepalive_time": 90} + assert ebgp.getTimers() == {"hold_time": 7200, "keepalive_time": 120} + assert get_ospf_timers(r1) == {"tick": 7, "hello": 11, "dead": 44} + assert {session["kind"]: (session["hold_time"], session["keepalive_time"]) for session in r1_sessions} == { + "ebgp": (7200, 120), + "ibgp": (5400, 90), + } + + assert "tick 7;" in r1_conf + assert 'interface "net0" { hello 11; dead 44; }' in r1_conf + assert "hold time 7200;" in r1_conf + assert "keepalive time 120;" in r1_conf + assert "hold time 5400;" in r1_conf + assert "keepalive time 90;" in r1_conf + + assert "interface net0\n ip ospf area 0\n ip ospf hello-interval 11\n ip ospf dead-interval 44" in r2_conf + assert " timers 120 7200" in r2_conf + assert " timers 90 5400" in r2_conf + + def test_ospf_router_transit_only_respects_explicit_stub_and_mask(): emu = Emulator() base = Base()