Networking with libvirt - The next level

6 minute read

In a previous article, I mentioned that I wanted to have a separate network as a DMZ. So on the host, I got a second bridge, and given that it was using a different NIC, I could use differnt VLANs using the managed switch behind it.

This worked quite well for a while. But as I wanted to extend this possibility to separate the networks even further, in smaller VLANs, this method proved to be quite problematic.

The initial requirement

My initial requirement was simple:

have several VLANs on one machine, each separated from each other, to prevent certain VMs to talk to others

I wanted to get this working using libvirt given that is what I use to manage my VMs. This may seem like an odd requirement to put, but I found that quite some articles will explain you how to use virtual interfaces or bridges with qemu, not everything applies out of the box with libvirt.

The research

So I went out there and tried to figure out what would be required to allow multiple VLANs on a single machine, have libvirt recognize it all, and have well separated networks.

Well… this wasn’t an easy ride.

All started with this RedHat article. I thought that I could make the “one bridge with VLAN filtering” work. I kind of did but I soon realized that it wasn’t really for me. My issue was that I couldn’t filter the traffic as I wanted, it was a “all pass or nothing does” kind of deal. Maybe it would have been possible if I was to implement the rules on the host firewall, but I realized that I didn’t want to maintain my global firewall and a firewall on the host.

So came my refined requirement!

The refined requirement

same as above, but I want to be able to manage the traffic through the global firewall

This implies a lot of things! The traffic exiting the physical interface(s) must be already tagged and the rest of the network appliances must follow!

The implementation

So after much research, trial and errors, I managed to have the following working:

        +---------+             +---------+             +---------+
        |   VM1   |             |   VM2   |             |   VM3   |
        | +-----+ |             | +-----+ |             | +-----+ |
        | | et1 | |             | | et2 | |             | | et3 | |
        +-+-----+-+             +-+-----+-+             +-+-----+-+
             |      +---------+      |                       |
             +------|   brd1  |------+                   +-------+
                    +---------+                          |  brd2 |
                         |                               +-------+
                         |                                   |
                       vlan200                            vlan201
                         |                                   |
                         +-----------------------------------+
                                           |
                                         bond0
                                         |   |
                                eno1-----+   +-----eno2

So, I have a bond interface (bond0) on top of two NICs (eno1 and eno2) in a “active-backup” mode (so if one NIC dies, the other one takes over).

On top of this bond, I am creating virtual interfaces with a tagged VLAN ID (vlan200 and vlan201).

I’m attaching a bridge on this VLAN interface so that I can have several VMs attached “in the same VLAN” (e.g. VM1 and VM2).

I’m not managin the et1, et2 or et3, those are created dynamically by libvirt when starting the VM in bridge mode.

Netplan

I’m using Ubuntu on my host (because ZFS) so I used netplan to manage all this. I’m not a huge fan of it but I managed to get it working, so why not. The nice thing though was that I could separate in different files what I needed.

Therefore, for bond creation, I have a file explaining the interfaces to bond (0002-interface-bonding.yaml):

 1network:
 2  version: 2
 3  renderer: networkd
 4  bonds:
 5    bond0:
 6      dhcp4: false
 7      link-local: [ ipv4 ]
 8      interfaces:
 9        - eno1
10        - eno2
11      parameters:
12        mode: active-backup
13        primary: eno1
14        mii-monitor-interval: 1
15        gratuitious-arp: 5

Then, I have several files to create the VLAN aware virtual interfaces (0003-vlan200.yaml) with the rquired VLAN ID:

1network:
2  vlans:
3    vlan200-int:
4      id: 200
5      link-local: [ ipv4 ]
6      link: bond0
7      dhcp4: false

And finally, I have the bridge interface to allow attach to the VLAN interface and for the VMs to connect to (0004-vlan200-bridge.yaml):

 1network:
 2  bridges:
 3    br-int:
 4      interfaces:
 5        - vlan200-int
 6      mtu: 1500
 7      parameters:
 8        stp: true
 9        forward-delay: 2
10      dhcp4: false
11      addresses:
12        - 10.0.200.2/24
13      routes:
14        - to: default
15          via: 10.0.200.1
16      nameservers:
17        addresses:
18          - 10.0.200.1

Please note that this bridge is a bit special because it acts as a default gateway globally, even though the other networks will not use it. This allows to update the host and other maintenance things (I know I could use the management interface for that but I didn’t want to for probably wrong reasons…).

So when I need/want a new VLAN, I simply create a new “VLAN interface” and bridge linked to that and here I go!

The switch

Given that my plan is to manage the network through the firewall, all the packets must be brought to the firewall with their VLAN ID. So this means that whenever I need to create a new VLAN on the host, not only should I add the necessary netplan files, but I also must update my switch to allow tagged packets on the ports which arrive to eno1 and eno2.

I am not a network specialist, I know enough to get going but not enough to have “advanced” networking concepts work from the first try just by imaging the architecture in my head. So there has been quite some trial and errors (again) due to the following aspect: tagged ports.

The receiving port in the switch, so the one where the cable comes from the switch to the eno1 and eno2 NICs, must be set to tagged. Otherwise, the VLAN IDs will be stripped and the firewall will not get what’s happening.

Another important aspect is that both switch receiving ports must be configured indentically given that behind, on the host, we’re using a bond interface (that one, I didn’t struggle with… yes, I’m proud of myself ;-)).

The firewall

Here, it will depend on your firewall. For me (OPNsense), it’s pretty simple:

  • create new interface
  • assign interface
  • filter traffic

The virtualization

As I’m using a bridge on top of the VLAN interface for each VLAN, I can simply use the brdige mode of libvirt which simplifies a lot the libvirt integration in all this: libvirt and the VMs are unaware of the VLANs behind the scenes.

So when I create a VM, I will use the following:

1virt-install ... --network bridge=br-int,model=virtio ...

I could also imagine having VMs with multiple interfaces, with each of then connected to a different bridge interface.

Final thoughts

There may be better ways, more advanced, flexible, optimized… But this one works for me.

The concepts are simple enough for me to use, extent, debug. There’s only one place where the filtering happens (my global firewall), so this solution reduces redundant configuration…

On a final note, I wanted to point to an article I read which is a fantastic resource regarding VLANs, Linux bridges, vETH… I tested it, got it to work (again, proud) but I quickly found that given that all happens on the host (bridge), I cannot (at least, I didn’t manage to make it work) upstream the VLAN IDs to the firewall.