Creating the ACME Topology Plugin

Recall that the ACME network will look like:

ACME Network Topology

The actual code that builds the topology is the ACME model component’s plugin which we specified as plugin.py in the MANIFEST file. Therefore, open the file acme/topology/plugin.py to get started.

Opening Our Plugin

Within every plugin file, FIREWHEEL automatically looks for a plugin class to define the plugin’s execution. This class must meet two criteria.

First, the class that gets declared must inherit from AbstractPlugin. This guarantees that the Experiment Graph instance is properly handled and located in the variable self.g. Additionally, this inheritance also provides a logger in self.log to each plugin to facilitate easy debugging.

Second, the plugin must have a run() method. This is the method that gets invoked by FIREWHEEL to kick off the plugin. The run() method can also take parameters, if needed, for the topology.

As a note, only one such plugin class may be defined per plugin file (more would be ambiguous and therefore will cause FIREWHEEL to raise an error).

With this context in mind, we will be editing the run() method first.

Open the file acme/topology/plugin.py to get started.

It should look something like this:

from firewheel.control.experiment_graph import AbstractPlugin, Vertex

class Plugin(AbstractPlugin):
    """acme.topology plugin documentation."""

    def run(self):
        """Run method documentation."""
        # TODO: Implement plugin actions here
        pass

Implementing the Plugin

Because this topology is more complex than a few VMs, we recommend splitting up the functionality into separate methods. We can view the network graph as four distinct sections; the Front, Building 1, Building 2, and the Data center. Therefore, we can build the sections in individual methods and tie them all together in run().

Set up

Before working on any particular part of the topology, we should first add a few necessary import statements. Add the following to the top of plugin.py.

from netaddr import IPNetwork

from firewheel.control.experiment_graph import Vertex, AbstractPlugin

from base_objects import Switch
from vyos.helium118 import Helium118
from linux.ubuntu2204 import Ubuntu2204Server, Ubuntu2204Desktop

These imports provide the necessary graph objects needed to create the topology. Additionally, we will use netaddr to facilitate assigning IP addresses easier.

Let’s initialize a few instance variables which can be used when creating the topology. Using netaddr we will create an external-facing network (e.g. “Internet” facing). We aren’t going to put anything external to the ACME enterprise in this tutorial, but you could add to this topology by providing external services. The external network is going to be 1.0.0.0/24.

Next, we need an internal network for routing between the distinct sections of the enterprise. We’re going to use 10.0.0.0/8 and the subsequently break up the network into subnet blocks each with 255 IP addresses.

While users can define IP addresses with simple Python strings, for complex topologies we recommend using the netaddr.IPNetwork and netaddr.IPAddress classes to specify various network spaces. netaddr.IPNetwork provides a generator (iter_hosts()) that allows you to walk through the entire IP space. The generator provides IPAddress objects. Therefore, we can use standard python syntax to get the next available IPAddress (e.g. next(network_iter)). We will use this generator method throughout the creation of the ACME topology.

Here is our initial run() method.

def run(self):
    """Run method documentation."""
    # Create an external-facing network and an iterator for that network.
    # The iterator will provide the next available netaddr.IPAddress for the given
    # network.
    self.external_network = IPNetwork("1.0.0.0/24")
    external_network_iter = self.external_network.iter_hosts()

    # Create an internal facing network
    internal_networks = IPNetwork("10.0.0.0/8")

    # Break the internal network into various subnets
    # https://netaddr.readthedocs.io/en/latest/tutorial_01.html#supernets-and-subnets
    self.internal_subnets = internal_networks.subnet(24)

Building the Front

Now we are ready to build out the first part of the topology which we will call the “front”. The “front” of the enterprise consists of the gateway router and the firewall. We will create a new method called build_front(). For this method, we can pass in the external IP network for the gateway.

def build_front(self, ext_ip):
    pass

Now we need to create the gateway router. We create a vertex by instantiating a Vertex object and passing it the graph (self.g) as well as the name of the vertex. The plugin.py template has already imported Vertex for you from firewheel.control.experiment_graph. Once we have created a Vertex, we can decorate it as a specific object type. In this case, we want the gateway to be a VyOS router using the Helium 1.1.8 release. We have imported the the Helium118 object already (from the vyos.helium118_mc MC) so we can use it to decorate our gateway.

Note

Vertices can be given default image types in the case where a specific image class isn’t known at topology creation. We could have decorated this Vertex with GenericRouter (from the generic_vm_objects_mc MC) and it would have subsequently been decorated as Helium118 due to the defaults set in the minimega.resolve_vm_images_mc model component. See minimega.resolve_vm_images_mc for more details.

def build_front(self, ext_ip):
    # Build the gateway
    gateway = Vertex(self.g, "gateway.acme.com")
    gateway.decorate(Helium118)

In FIREWHEEL Switches are essentially virtual network bridges which help connect two VMs. Users can make a Switch into a VM if some specific switching technique is being evaluated, but typically, they will just be instantiated as an Open vSwitch bridge that is transparent to the VMs within the experiment. A Switch is created in a way that is very similar to the routers. The only difference is that the Vertex is decorated with Switch, which can be imported from base_objects_mc.

We can now create our “external” switch.

# Create the external switch
ext_switch = Vertex(self.g, name="ACME-EXTERNAL")
ext_switch.decorate(Switch)

Now that we have the Switch and the gateway, we can connect them together. We will use the IP address which was passed into the method.

# Connect the gateway to the external switch
gateway.connect(
    ext_switch,  # The "Internet" facing Switch
    ext_ip,  # The external IP address for the gateway (e.g. 1.0.0.1)
    self.external_network.netmask  # The external subnet mask (e.g. 255.255.255.0)
)

We then do the same thing to create the firewall and the switch to connect the firewall to the gateway.

# Build a switch to connect the gateway and firewall
gateway_firewall_switch = Vertex(self.g, name="GW-FW")
gateway_firewall_switch.decorate(Switch)

# Build the firewall
firewall = Vertex(self.g, "firewall.acme.com")
firewall.decorate(Helium118)

We will want to create a network to generate the IP address for the gateway/firewall connection. To do so, we can grab the next available subnet from our self.internal_subnets generator.

# Create a network and a generator for the network between
# the gateway and firewall.
gateway_firewall_network = next(self.internal_subnets)
gateway_firewall_network_iter = gateway_firewall_network.iter_hosts()

Since this is a network local to the enterprise, let’s have the routers use the OSPF routing protocol. When using OSPF, you can call the ospf_connect() method on the router. The ospf_connect() method requires that you specify the switch to connect the router to, the IP address for the router’s interface, and the netmask of the IP. This method takes care of all the relevant details to make sure that OSPF works on the newly created network interface.

# Connect the gateway and the firewall to their respective switches
# We will use ``ospf_connect`` to ensure that the OSPF routes are propagated
# correctly (as we want to use OSPF as routing protocol inside of the ACME network).
gateway.ospf_connect(
    gateway_firewall_switch,
    next(gateway_firewall_network_iter),
    gateway_firewall_network.netmask,
)
firewall.ospf_connect(
    gateway_firewall_switch,
    next(gateway_firewall_network_iter),
    gateway_firewall_network.netmask,
)

Finally, we return the firewall Vertex so that other parts of the topology can connect to them as well.

return firewall

The full build_front() method is:

def build_front(self, ext_ip):
    """Build the ACME infrastructure that is Internet-facing.

    This method will create the following topology::

            switch -- gateway -- switch -- firewall
        (ACME-EXTERNAL)         (GW-FW)

    Args:
        ext_ip (netaddr.IPAddress): The external IP address for the gateway
            (e.g. its Internet facing IP address).

    Returns:
        vyos.Helium118: The Firewall object.
    """

    # Build the gateway
    gateway = Vertex(self.g, "gateway.acme.com")
    gateway.decorate(Helium118)

    # Create the external switch
    ext_switch = Vertex(self.g, name="ACME-EXTERNAL")
    ext_switch.decorate(Switch)

    # Connect the gateway to the external switch
    gateway.connect(
        ext_switch,  # The "Internet" facing Switch
        ext_ip,  # The external IP address for the gateway (e.g. 1.0.0.1)
        self.external_network.netmask  # The external subnet mask (e.g. 255.255.255.0)
    )

    # Build a switch to connect the gateway and firewall
    gateway_firewall_switch = Vertex(self.g, name="GW-FW")
    gateway_firewall_switch.decorate(Switch)

    # Build the firewall
    firewall = Vertex(self.g, "firewall.acme.com")
    firewall.decorate(Helium118)

    # Create a network and a generator for the network between
    # the gateway and firewall.
    gateway_firewall_network = next(self.internal_subnets)
    gateway_firewall_network_iter = gateway_firewall_network.iter_hosts()

    # Connect the gateway and the firewall to their respective switches
    # We will use ``ospf_connect`` to ensure that the OSPF routes are propagated
    # correctly (as we want to use OSPF as routing protocol inside of the ACME network).
    gateway.ospf_connect(
        gateway_firewall_switch,
        next(gateway_firewall_network_iter),
        gateway_firewall_network.netmask,
    )
    firewall.ospf_connect(
        gateway_firewall_switch,
        next(gateway_firewall_network_iter),
        gateway_firewall_network.netmask,
    )
    return firewall

Updating run()

Now that we have a method which will create the first part of our ACME network, we can update our run() method to call build_front(). Recall that we needed to pass in the an external IP address.

def run(self):
    ...
    # Create the gateway and firewall
    firewall = self.build_front(next(external_network_iter))

Now, we can create a new Switch and a new subnet which will be used to connect both buildings to the firewall.

def run(self):
    ...
    # Create the gateway and firewall
    firewall = self.build_front(next(external_network_iter))

    # Create an internal switch
    internal_switch = Vertex(self.g, name="ACME-INTERNAL")
    internal_switch.decorate(Switch)

    # Grab a subnet to use for connections to the internal switch
    internal_switch_network = next(self.internal_subnets)
    # Create a generator for the network
    internal_switch_network_iter = internal_switch_network.iter_hosts()

Once we have set up internal network and Switch, we can connect the firewall to that switch.

def run(self):
    ...
    # Create a generator for the network
    internal_switch_network_iter = internal_switch_network.iter_hosts()

    # Connect the firewall to the internal switch
    firewall.ospf_connect(
        gateway_firewall_switch,
        next(gateway_firewall_network_iter),
        gateway_firewall_network.netmask,
    )

We are now ready to create a method to create a building.

Implementing build_building()

With the front done, it’s now time to create an ACME building. Since we want multiple buildings and the buildings themselves are very similar, we can make a single build_building() method that gets called multiple times from run(). We will want to pass in several method parameters to build_building():

  • name - The name of the building (e.g. "building1").

  • network - The netaddr.IPNetwork subnet for the particular building. This will be used to connect all hosts within the building.

  • num_hosts - The number of hosts the building should have.

def build_building(self, name, network, num_hosts=1):
    pass

Every building needs a router in order to connect to the rest of the ACME enterprise. We use the name that was provided to the function as the name for the router. Like the routers that were created in build_front(), we decorate the Vertex as a Helium118 router.

def build_building(self, name, network, num_hosts=1):
    """Create the building router and hosts.

    This is a single router with all of the hosts.
    Assuming that the building is called "building1" the topology will look like::

            switch ---- building1 ----- switch ------ hosts
        (ACME-INTERNAL)           (building1-switch)

    Args:
        name (str): The name of the building.
        network (netaddr.IPNetwork): The subnet for the building.
        num_hosts (int): The number of hosts the building should have.

    Returns:
        vyos.Helium118: The building router.
    """

    # Create the VyOS router which will connect the building to the ACME network.
    building = Vertex(self.g, name=f"{name}.acme.com")
    building.decorate(Helium118)

Now, we can create a new Switch and a new IPAddress generator which will be used to connect the building router to all building hosts.

# Create the building-specific switch
building_sw = Vertex(self.g, name=f"{name}-switch")
building_sw.decorate(Switch)

# Create a generator for the building's network
building_network_iter = network.iter_hosts()

We can now connect the building router to the building Switch. In this case, because none of the hosts use OSPF to communicate, we can directly connect the router to the switch using the connect() (rather than using ospf_connect()). However, because the ACME internal network uses OSPF to communicate, we will want to ensure that the building can be discovered by the rest of the ACME network. Therefore, we use the redistribute_ospf_connected() method to redistribute (i.e., advertise) networks that it is directly connected to (i.e., the building’s network). This will make the hosts routable (and discoverable) throughout the rest of the ACME enterprise.

# Create the building-specific switch
building_sw = Vertex(self.g, name=f"{name}-switch")
building_sw.decorate(Switch)

# Create a generator for the building's network
building_network_iter = network.iter_hosts()

# Connect the building to the building Switch
building.connect(building_sw, next(building_network_iter), network.netmask)

# This redistributes routes for directly connected subnets to OSPF peers.
# That is, enables these peers to be discoverable by the rest of the OSPF
# routing infrastructure.
building.redistribute_ospf_connected()

The building has a parameter which defines the number of end hosts that require access to the enterprise network (i.e. num_hosts). We can use a loop to create the requisite number of hosts. In this case, we want to decorate our vertices with Ubuntu2204Desktop, which we imported from the linux.ubuntu2204_mc. Once each host is created, we can add it to the building Switch.

# Create the correct number of hosts
for i in range(num_hosts):
    # Create a new host which is a Ubuntu Desktop
    host = Vertex(
        self.g,
        name=f"{name}-host-{i}.acme.com",  # e.g. "building1-host-1.acme.com"
    )
    host.decorate(Ubuntu2204Desktop)

    # Connect the host to the building's switch
    host.connect(
        building_sw,  # The building switch
        next(building_network_iter),  # The next available building IP address
        network.netmask,  # The building's subnet mask
    )

Now that all the hosts are connected we can return the building router to connect it to the ACME-INTERNAL Switch.

return building

The full build_front() method is:

def build_building(self, name, network, num_hosts=1):
    """Create the building router and hosts.

    This is a single router with all of the hosts.
    Assuming that the building is called "building1" the topology will look like::

            switch ---- building1 ----- switch ------ hosts
        (ACME-INTERNAL)           (building1-switch)

    Args:
        name (str): The name of the building.
        network (netaddr.IPNetwork): The subnet for the building.
        num_hosts (int): The number of hosts the building should have.

    Returns:
        vyos.Helium118: The building router.
    """

    # Create the VyOS router which will connect the building to the ACME network.
    building = Vertex(self.g, name=f"{name}.acme.com")
    building.decorate(Helium118)

    # Create the building-specific switch
    building_sw = Vertex(self.g, name=f"{name}-switch")
    building_sw.decorate(Switch)

    # Create a generator for the building's network
    building_network_iter = network.iter_hosts()

    # Connect the building to the building Switch
    building.connect(building_sw, next(building_network_iter), network.netmask)

    # This redistribute routes for directly connected subnets to OSPF peers.
    # That is, enables these peers to be discoverable by the rest of the OSPF
    # routing infrastructure.
    building.redistribute_ospf_connected()

    # Create the correct number of hosts
    for i in range(num_hosts):
        # Create a new host which is a Ubuntu Desktop
        host = Vertex(
            self.g,
            name=f"{name}-host-{i}.acme.com",  # e.g. "building1-host-1.acme.com"
        )
        host.decorate(Ubuntu2204Desktop)

        # Connect the host to the building's switch
        host.connect(
            building_sw,  # The building switch
            next(building_network_iter),  # The next available building IP address
            network.netmask,  # The building's subnet mask
        )

    return building

Adding buildings to run()

Recall that we had just connected the firewall to the internal switch in the run() method.

def run(self):
    ...
    # Connect the Firewall to the internal switch
    firewall.ospf_connect(
        internal_switch,
        next(internal_switch_network_iter),
        internal_switch_network.netmask,
    )

We already have all the pieces in place to create our two buildings and connect them to the same internal switch.

def run(self):
    ...
   building_1 = self.build_building(
        "building1",  # The name of the building
        next(self.internal_subnets),  # The building network
        num_hosts=3,  # The number of hosts for the building
    )

    # Connect the first building router to the internal switch.
    building_1.ospf_connect(
        internal_switch,
        next(internal_switch_network_iter),
        internal_switch_network.netmask,
    )

    # Create our second building
    building_2 = self.build_building(
        "building2",  # The name of the building
        next(self.internal_subnets),  # The building network
        num_hosts=3,  # The number of hosts for the building
    )

    # Connect the second building router to the internal switch.
    building_2.ospf_connect(
        internal_switch,
        next(internal_switch_network_iter),
        internal_switch_network.netmask,
    )

Now that our buildings are ready, we can begin constructing the data center. This will also be a separate method.

Implementing build_datacenter()

The data center hosts the servers for our fictional ACME enterprise. The slight difference for this method is that the data center is housed in Building 2 and therefore uses the Building 2 router to connect into the rest of the internal network. This is set by passing in the Building 2 router as the first parameter of the build_datacenter() method. Additionally, the data center will need two different networks, one for connecting the DC to the building and a second for connecting the DC to its various servers.

def build_datacenter(self, building, uplink_network, dc_network):
    pass

The build_datacenter() method will look similar to creating a building. We will create a DC Vertex and decorate it as a a Helium118 router. We will then create a Switch and an IPAddress generator from the uplink_network and connect the DC router and the building router to the switch using the ospf_connect() method. Next, we will create the internal DC switch and a for loop to create all of our servers. In this case, the servers will be decorated as a Ubuntu2204Server (rather than a Ubuntu2204Desktop).

The full method looks like:

def build_datacenter(self, building, uplink_network, dc_network):
    """Create the data center.

    This is a single router with all of the servers::

       building2 ------ switch ------ datacenter ------ switch ------ servers
                 (building2-DC-switch)                (DC-switch)

    Args:
        building (vyos.Helium118): The Building router which contains the data center.
        uplink_network (netaddr.IPNetwork): The network to connect the DC to the building.
        dc_network (netaddr.IPNetwork): The network for the data center.
    """
    # Create a switch to connect the DC with the building
    building_dc_sw = Vertex(self.g, name=f"{building.name}-DC-switch")
    building_dc_sw.decorate(Switch)

    # Create the datacenter router
    datacenter = Vertex(self.g, name="datacenter.acme.com")
    datacenter.decorate(Helium118)

    # Create a generator for the building's network
    uplink_network_iter = uplink_network.iter_hosts()

    # Connect the building to the building-DC-switch
    building.ospf_connect(
        building_dc_sw, next(uplink_network_iter), uplink_network.netmask
    )

    # Connect the datacenter to the building-DC-switch
    datacenter.ospf_connect(
        building_dc_sw, next(uplink_network_iter), uplink_network.netmask
    )

    # Make the datacenter internal switch and connect
    datacenter_sw = Vertex(self.g, name="DC-switch")
    datacenter_sw.decorate(Switch)

    # Create a generator for the DC's network
    dc_network_iter = dc_network.iter_hosts()

    # Connect the DC to the internal switch
    datacenter.connect(datacenter_sw, next(dc_network_iter), dc_network.netmask)

    # This redistribute routes for directly connected subnets to OSPF peers.
    # That is, enables these peers to be discoverable by the rest of the OSPF
    # routing infrastructure.
    datacenter.redistribute_ospf_connected()

    # Make servers
    for i in range(3):
        # Create a new Ubuntu server and add connect it to the DC network switch
        server = Vertex(self.g, name=f"datacenter-{i}.acme.com")
        server.decorate(Ubuntu2204Server)
        server.connect(datacenter_sw, next(dc_network_iter), dc_network.netmask)

Calling build_datacenter()

Lastly, we need to call the build_datacenter() from our run function. You can simply add the following lines to the bottom of run().

# Build our data center
self.build_datacenter(
    building_2,  # The building Vertex
    next(
        self.internal_subnets
    ),  # Add a network to connect the DC to the building
    next(self.internal_subnets),  # Add a network which is internal to the DC
)

Here is what the full run() function should look like:

def run(self):
    """Run method documentation."""
    # Create an external-facing network and an iterator for that network.
    # The iterator will provide the next available netaddr.IPAddress for the given
    # network.
    self.external_network = IPNetwork("1.0.0.0/24")
    external_network_iter = self.external_network.iter_hosts()

    # Create an internal facing network
    internal_networks = IPNetwork("10.0.0.0/8")

    # Break the internal network into various subnets
    # https://netaddr.readthedocs.io/en/latest/tutorial_01.html#supernets-and-subnets
    self.internal_subnets = internal_networks.subnet(24)

    # Create the gateway and firewall
    firewall = self.build_front(next(external_network_iter))

    # Create an internal switch
    internal_switch = Vertex(self.g, name="ACME-INTERNAL")
    internal_switch.decorate(Switch)

    # Grab a subnet to use for connections to the internal switch
    internal_switch_network = next(self.internal_subnets)
    # Create a generator for the network
    internal_switch_network_iter = internal_switch_network.iter_hosts()

    # Connect the Firewall to the internal switch
    firewall.ospf_connect(
        internal_switch,
        next(internal_switch_network_iter),
        internal_switch_network.netmask,
    )

    # Create our first building
    building_1 = self.build_building(
        "building1",  # The name of the building
        next(self.internal_subnets),  # The building network
        num_hosts=3,  # The number of hosts for the building
    )

    # Connect the first building router to the internal switch.
    building_1.ospf_connect(
        internal_switch,
        next(internal_switch_network_iter),
        internal_switch_network.netmask,
    )

    # Create our second building
    building_2 = self.build_building(
        "building2",  # The name of the building
        next(self.internal_subnets),  # The building network
        num_hosts=3,  # The number of hosts for the building
    )

    # Connect the second building router to the internal switch.
    building_2.ospf_connect(
        internal_switch,
        next(internal_switch_network_iter),
        internal_switch_network.netmask,
    )

    # Build our data center
    self.build_datacenter(
        building_2,  # The building Vertex
        next(
            self.internal_subnets
        ),  # Add a network to connect the DC to the building
        next(self.internal_subnets),  # Add a network which is internal to the DC
    )

Putting it All Together

At this point, you can save your file and close the editor. It is now time to verify that your topology works as expected.

For reference, the full plugin.py file should look something like this:

from netaddr import IPNetwork

from firewheel.control.experiment_graph import Vertex, AbstractPlugin

from base_objects import Switch
from vyos.helium118 import Helium118
from linux.ubuntu2204 import Ubuntu2204Server, Ubuntu2204Desktop


class Plugin(AbstractPlugin):
    """acme.topology plugin documentation."""

    def run(self):
        """Run method documentation."""
        # Create an external-facing network and an iterator for that network.
        # The iterator will provide the next available netaddr.IPAddress for the given
        # network.
        self.external_network = IPNetwork("1.0.0.0/24")
        external_network_iter = self.external_network.iter_hosts()

        # Create an internal facing network
        internal_networks = IPNetwork("10.0.0.0/8")

        # Break the internal network into various subnets
        # https://netaddr.readthedocs.io/en/latest/tutorial_01.html#supernets-and-subnets
        self.internal_subnets = internal_networks.subnet(24)

        # Create the gateway and firewall
        firewall = self.build_front(next(external_network_iter))

        # Create an internal switch
        internal_switch = Vertex(self.g, name="ACME-INTERNAL")
        internal_switch.decorate(Switch)

        # Grab a subnet to use for connections to the internal switch
        internal_switch_network = next(self.internal_subnets)
        # Create a generator for the network
        internal_switch_network_iter = internal_switch_network.iter_hosts()

        # Connect the Firewall to the internal switch
        firewall.ospf_connect(
            internal_switch,
            next(internal_switch_network_iter),
            internal_switch_network.netmask,
        )

        # Create our first building
        building_1 = self.build_building(
            "building1",  # The name of the building
            next(self.internal_subnets),  # The building network
            num_hosts=3,  # The number of hosts for the building
        )

        # Connect the first building router to the internal switch.
        building_1.ospf_connect(
            internal_switch,
            next(internal_switch_network_iter),
            internal_switch_network.netmask,
        )

        # Create our second building
        building_2 = self.build_building(
            "building2",  # The name of the building
            next(self.internal_subnets),  # The building network
            num_hosts=3,  # The number of hosts for the building
        )

        # Connect the second building router to the internal switch.
        building_2.ospf_connect(
            internal_switch,
            next(internal_switch_network_iter),
            internal_switch_network.netmask,
        )

        # Build our data center
        self.build_datacenter(
            building_2,  # The building Vertex
            next(
                self.internal_subnets
            ),  # Add a network to connect the DC to the building
            next(self.internal_subnets),  # Add a network which is internal to the DC
        )

    def build_front(self, ext_ip):
        """Build the ACME infrastructure that is Internet-facing.

        This method will create the following topology::

                switch -- gateway -- switch -- firewall
            (ACME-EXTERNAL)         (GW-FW)

        Args:
            ext_ip (netaddr.IPAddress): The external IP address for the gateway
                (e.g. its Internet facing IP address).

        Returns:
            vyos.Helium118: The Firewall object.
        """

        # Build the gateway
        gateway = Vertex(self.g, "gateway.acme.com")
        gateway.decorate(Helium118)

        # Create the external switch
        ext_switch = Vertex(self.g, name="ACME-EXTERNAL")
        ext_switch.decorate(Switch)

        # Connect the gateway to the external switch
        gateway.connect(
            ext_switch,  # The "Internet" facing Switch
            ext_ip,  # The external IP address for the gateway (e.g. 1.0.0.1)
            self.external_network.netmask,  # The external subnet mask (e.g. 255.255.255.0)
        )

        # Build a switch to connect the gateway and firewall
        gateway_firewall_switch = Vertex(self.g, name="GW-FW")
        gateway_firewall_switch.decorate(Switch)

        # Build the firewall
        firewall = Vertex(self.g, "firewall.acme.com")
        firewall.decorate(Helium118)

        # Create a network and a generator for the network between
        # the gateway and firewall.
        gateway_firewall_network = next(self.internal_subnets)
        gateway_firewall_network_iter = gateway_firewall_network.iter_hosts()

        # Connect the gateway and the firewall to their respective switches
        # We will use ``ospf_connect`` to ensure that the OSPF routes are propagated
        # correctly (as we want to use OSPF as routing protocol inside of the ACME network).
        gateway.ospf_connect(
            gateway_firewall_switch,
            next(gateway_firewall_network_iter),
            gateway_firewall_network.netmask,
        )
        firewall.ospf_connect(
            gateway_firewall_switch,
            next(gateway_firewall_network_iter),
            gateway_firewall_network.netmask,
        )
        return firewall

    def build_building(self, name, network, num_hosts=1):
        """Create the building router and hosts.

        This is a single router with all of the hosts.
        Assuming that the building is called "building1" the topology will look like::

                switch ---- building1 ----- switch ------ hosts
            (ACME-INTERNAL)           (building1-switch)

        Args:
            name (str): The name of the building.
            network (netaddr.IPNetwork): The subnet for the building.
            num_hosts (int): The number of hosts the building should have.

        Returns:
            vyos.Helium118: The building router.
        """

        # Create the VyOS router which will connect the building to the ACME network.
        building = Vertex(self.g, name=f"{name}.acme.com")
        building.decorate(Helium118)

        # Create the building-specific switch
        building_sw = Vertex(self.g, name=f"{name}-switch")
        building_sw.decorate(Switch)

        # Create a generator for the building's network
        building_network_iter = network.iter_hosts()

        # Connect the building to the building Switch
        building.connect(building_sw, next(building_network_iter), network.netmask)

        # This redistribute routes for directly connected subnets to OSPF peers.
        # That is, enables these peers to be discoverable by the rest of the OSPF
        # routing infrastructure.
        building.redistribute_ospf_connected()

        # Create the correct number of hosts
        for i in range(num_hosts):
            # Create a new host which is a Ubuntu Desktop
            host = Vertex(
                self.g,
                name=f"{name}-host-{i}.acme.com",  # e.g. "building1-host-1.acme.com"
            )
            host.decorate(Ubuntu2204Desktop)

            # Connect the host to the building's switch
            host.connect(
                building_sw,  # The building switch
                next(building_network_iter),  # The next available building IP address
                network.netmask,  # The building's subnet mask
            )

        return building

    def build_datacenter(self, building, uplink_network, dc_network):
        """Create the data center.

        This is a single router with all of the servers::

        building2 ------ switch ------ datacenter ------ switch ------ servers
                    (building2-DC-switch)                (DC-switch)

        Args:
            building (vyos.Helium118): The Building router which contains the data center.
            uplink_network (netaddr.IPNetwork): The network to connect the DC to the building.
            dc_network (netaddr.IPNetwork): The network for the data center.
        """
        # Create a switch to connect the DC with the building
        building_dc_sw = Vertex(self.g, name=f"{building.name}-DC-switch")
        building_dc_sw.decorate(Switch)

        # Create the datacenter router
        datacenter = Vertex(self.g, name="datacenter.acme.com")
        datacenter.decorate(Helium118)

        # Create a generator for the building's network
        uplink_network_iter = uplink_network.iter_hosts()

        # Connect the building to the building-DC-switch
        building.ospf_connect(
            building_dc_sw, next(uplink_network_iter), uplink_network.netmask
        )

        # Connect the datacenter to the building-DC-switch
        datacenter.ospf_connect(
            building_dc_sw, next(uplink_network_iter), uplink_network.netmask
        )

        # Make the datacenter internal switch and connect
        datacenter_sw = Vertex(self.g, name="DC-switch")
        datacenter_sw.decorate(Switch)

        # Create a generator for the DC's network
        dc_network_iter = dc_network.iter_hosts()

        # Connect the DC to the internal switch
        datacenter.connect(datacenter_sw, next(dc_network_iter), dc_network.netmask)

        # This redistribute routes for directly connected subnets to OSPF peers.
        # That is, enables these peers to be discoverable by the rest of the OSPF
        # routing infrastructure.
        datacenter.redistribute_ospf_connected()

        # Make servers
        for i in range(3):
            # Create a new Ubuntu server and add connect it to the DC network switch
            server = Vertex(self.g, name=f"datacenter-{i}.acme.com")
            server.decorate(Ubuntu2204Server)
            server.connect(datacenter_sw, next(dc_network_iter), dc_network.netmask)