DEV Community

Cover image for Virtualization on Debian with libvirt&QEMU&KVM — Networking beyond "default": Forwarding Incoming Connections to NAT'ed network
Anna
Anna

Posted on

Virtualization on Debian with libvirt&QEMU&KVM — Networking beyond "default": Forwarding Incoming Connections to NAT'ed network

This is the second part of "Networking Beyond "Default". All must-have theory was explained in detail in the previous part, this article will be very practical.

If your understanding of these concepts is very wobbly, I strongly recommend reading the previous article:

  • nftables rules that perform network address translation
  • The structure of IPv4 addresses and CIDR
  • Physical vs Virtual Network Interfaces
  • Virtual network switches a.k.a bridges

Terminology I will use in this article:

Host – This refers to your PC, on which you set up virtualization and create virtual machines.
Guest – This is any virtual machine you create. VM/VMs – A virtual machine/Virtual Machines.
LAN – Local Area Network managed by my WiFi Router provided by ISP(Internet service Provider)
NAT – Network Address Translation.
DEFAULT network – the Libvirt's virtual network that virtual machines are connected to by default (gets started with sudo virsh net-start default)
Packet - a network packet is a formatted unit of data carried by a network.


As I have spitted the content on virtualization into 3+ articles, I’ll start with a quick overview of my current setup and the goals I want to achieve with different network configurations.

  1. My virtual machines are created under qemu:///system, which means I run all virsh commands with sudo. Using sudo privileges allows me to configure the network as I need, whereas qemu:///session has very limited permissions in hardware virtualization (for more details, check this article).

  2. My host machine runs Debian, with nftables managing network traffic and firewalld serving as the firewall management tool.

  3. I have a virtual machine running MongoDB (VM name: deb-mongo). This VM was created in the most basic way and is attached to the DEFAULT libvirt network. Here’s what that entails:

  • The deb-mongo VM can communicate with other VMs connected to the same DEFAULT network, which operates in the NAT mode.

  • The VM can communicate with the host in both directions—whether the host initiates the connection (e.g., via SSH) or the VM initiates it.

  • The VM has access to the internet, allowing me to install MongoDB, update system packages, use ping, curl, wget, and so on.

  • No device on my local home network (the same network the host is on) can access this VM directly, and of course, nothing from the outside my home LAN can reach it either.

What I want to achieve, for demonstration purposes, is to make the deb-mongo VM reachable from my laptop, which is on the same local home network as the host (My Desktop PC). I want to be able to connect to MongoDB running on the VM using the MongoDB Compass GUI client on my laptop.


In the previous article, I analyzed libvirt's DEFAULT network in detail and broke its configuration down into small pieces. If you were paying close attention, you would have already understood which nftables rules block connections from anything other than the host machine to the VMs connected to DEFAULT network.

In the scope of this article I plan to enable access to the VM from devices connected to my LAN by modifying nftables rules of DEFAULT network, plus configure port forwarding on the host.

This option is viable, however, it is about turning a well-defined libvirt’s DEFAULT network into something different, which it was not meant to be.

I wrote this tutorial for educational purposes as I noticed that the questions about how to do it can be found on all possible forums for different distros. Personally, I do not use this network configuration in my home lab, and later in this article it will become clear why.

At some point, it’s better to define a new virtual network from scratch and define rules and NAT for it, rather than modify libvirt’s DEFAULT one, if you are very “NATophile”.

However, I can assume, that you are not, just maybe you think that is the only option to make VMs reachable for other devices connected to LAN. But it is not true. In the next article I will cover more suitable network configuration for the use case when VMs should be accessible members of LAN.

Now, let's begin with DEFAULT network tweaking!


Here is the roadmap for this article:

➀ DEFAULT libvirt network

  • ➀.➀ virbrN and vnetN - what do they do?
  • ➀.➁ About DHCP

➁ Configuring static IPv4 address on VM

  • ➁.➀ Shrinking DHCP range
  • ➁.➁ Modifying /etc/network/interfaces

➂ Connecting to a VM from Another Device on the LAN using the Port Forwarding Method:

  • ➂.➀ What is the Port Forwarding?
  • ➂.➁ Firewalld and open ports
  • ➂.➂ Forwarding ports on the host with firewalld
  • ➂.➃ Adding a rule to nftables ruleset to accept inbound network traffic to the VM
  • ➂.➄ Considerable drawbacks of this method

➀ DEFAULT libvirt network

First, I start the DEFAULT network, then I start the VM deb-mongo and enter its console.

# ip a serves to show when virtual network interfaces appear
$ ip a
1: lo
 ...
2: eno1:
    inet 192.168.1.X/24 brd 
3: wlx123456789kl:
    inet 192.168.1.Y/24

$ sudo virsh net-start default

$ ip a
1: lo
 ...
2: eno1:
    inet 192.168.1.X/24 brd 
3: wlx123456789kl:
    inet 192.168.1.Y/24
4: virbr0:
    inet 192.168.122.1/24

$ sudo virsh start deb-mongo

$ ip a
1: lo
 ...
2: eno1:
    inet 192.168.1.X/24 brd 
3: wlx123456789kl:
    inet 192.168.1.Y/24
4: virbr0:
    inet 192.168.122.1/24
5: vnet0: 
    inet ???

$ sudo virsh console deb-mongo
Enter fullscreen mode Exit fullscreen mode

➀.➀ virbrN and vnetN - what do they do?

First, I want to schematize what these virbr0 and vnet0 network interfaces. They weren’t there initially (ip link/ip a before sudo virsh net-start default did not contain them in outputs), but virbr0 showed up after I started the DEFAULT network, and vnet0 appeared when I started the deb-mongo VM.

Image description

virbr0 is the virtual network switch, and while its name suggests it is a bridge (and bridges are generally used to connect different networks), in the case of the DEFAULT libvirt network, virbr0 doesn't bridge anything - it does not bridge the DEFAULT network (192.168.122.0/24) to any of the networks the host is connected to. In a more physical sense, no physical network interface is plugged into this virbr0. All network traffic between VM and host, between VM and the outside world is governed by NAT (Network Address Translation) configuration.

This is partially why no VM connected to the DEFAULT network can be reached from other devices on your local home network, which the host is connected to. The other reason lies in the rules that define NAT, which essentially translate and isolate the internal network traffic.

vnet0 is a network TUN device. TUN/TAP devices are kernel virtual network devices. Being network devices supported entirely in software, they differ from ordinary network devices which are backed by physical network adapters.

The guest VM will have an associated tun device created with a name of vnetN, which can also be overridden with the element. The tun device will be attached to the bridge.This provides the guest VM full incoming & outgoing net access just like a physical machine. (Libvirt:Network Interfaces)

Let's move on.

If you check above the latest ip a output from my host PC, you'll notice there is no IPv4 address associated with the vnet0 interface, which is the virtual network interface of my VM deb-mongo. So, to proceed, I need to check the IP address of the VM directly from the VM itself.

I want to connect to the MongoDB instance running on this VM using the MongoDB Compass GUI client installed on my host machine. To do this, the first step is finding the IP address of the VM.

user-mongo@deb-mongo:~$ ip a
1: lo: 
...
2: enp1s0: 
    inet 192.168.122.196/24 brd 192.168.122.255
Enter fullscreen mode Exit fullscreen mode

That's quite a strange IP address, especially considering this is the second VM created in this network. 192.168.122.1 is the address used by virbr0, which acts as the router/gateway for this DEFAULT network (I'll elaborate more on it later). So why is my VM's IP .196 instead of something like .2 or .3? Where did it get this address? The answer is that it got it from DHCP - the Dynamic Host Configuration Protocol.

➀.➁ About DHCP

When you set up any network, any device connecting to this network needs to have certain information, such as the IP-address of its interface, the IP-address of at least one domain name server, and the IP-address of a server in the LAN that serves as a router to the internet. In the manual setup you have to type in this information for each client anew. With the Dynamic Host Configuration Protocol (DHCP) the computers can do that automatically for you. (Source)

Each virtual network switch can be given a range of IP addresses, to be provided to guests through DHCP.
Libvirt uses a program, dnsmasq, for this. An instance of dnsmasq is automatically configured and started by libvirt for each virtual network switch needing it. (Libvirt: Network Address Translation-NAT)

Image description

As stated in the libvirt documentation quoted above, I expect to find in the network configuration the range of IPs that the DHCP server is configured to assign. Indeed:

$ sudo virsh net-dumpxml default

<network>
  <name>default</name>
  <uuid>2becd6d0-5a0f-4b45-afff-5a518370fc8c</uuid>
  <forward mode='nat'>
    <nat>
      <port start='1024' end='65535'/>
    </nat>
  </forward>
  <bridge name='virbr0' stp='on' delay='0'/>
  <mac address='MA:C:AD:DD:RE:SS'/>
  <ip address='192.168.122.1' netmask='255.255.255.0'>
    <dhcp>
      <range start='192.168.122.2' end='192.168.122.254'/>
    </dhcp>
  </ip>
</network>
Enter fullscreen mode Exit fullscreen mode

DHCP automatically assigns IPs to the connected to DEFAULT network virtual machines from the range 192.168.122.2 - 192.168.122.254, based on availability rather than strictly sequential allocation. That’s why my VM ended up with .196.

What DHCP does respect when assigning IPs is that it keeps track of what’s happening in the network it manages. It won’t assign an IP that’s already currently occupied to new VMs joining the network. However, unless configured otherwise, DHCP won’t intervene if two VMs somehow end up with the same IP, and that can create a mess.

Why would this happen, you might ask? Well, it’s not uncommon to change the DHCP mode from dynamic IP allocation to static IPs (manually assigned). With the default setup, DHCP assigns dynamic addresses, so when a device connects to the network, it gets an IP like X. But if it disconnects and reconnects later, it might be assigned a completely different IP like Y, which can be very inconvenient for certain services that need to be accessed remotely.


➁ Configuring static IPv4 address on a VM

NB! It’s not being said for sure that DHCP is some crazy guy randomly assigning IP addresses by just looking at new connections and completely ignoring whether a device (like a VM's network interface) was already connected before. In fact, it’s very common for the IP address assigned once to persist. This is because the VM’s network interface, even if virtual, has a consistent and unchanging MAC address - so, it can be identified.

Anytime the VM reconnects to the same network, it will 99.9% get the same IP address as it was assigned the first time. However, sometimes certain factors can cause DHCP to reassign a different address.

On top of that, you may want to bring some order to your VMs, especially if there’s a logical structure to them, and you’d like more control over their network configuration. Assigning static IP addresses can help with this. While static IPs introduce some network fragility, they also improve network security, as you can create firewall rules, nftables rules, etc., based on the fixed IPs.

Of course, you can still set up such rules with DHCP-assigned dynamic IPs. However, the problem arises if the VM’s IP address changes for any reason. In that case, all the carefully imposed network traffic rules would collapse, and the setup would need to be reconfigured.

Moreover, knowing how to configure VM's network interface in the way you want it to is very nice skill to have, IMHO.

To manually assign a static IP to a VM, there are two things to respect: the range of network - I cannot assign IP address out of range; unique IP address - no any other VMs should have the same IP.

➁.➀ Shrinking DHCP range

To prevent, from the start, the possibility that DHCP assigns to a new VM an IP address I manually glued to one of the VMs by reconfiguring its network interface, I have to modify the DEFAULT network configuration and shrink the DHCP range—the set of available addresses that DHCP can use to assign.

Currently, this range occupies all the addresses available in the 192.168.122.0/24 network—all 254 addresses, excluding those reserved for the gateway/router (virbr0) and the broadcast address. By shrinking this range, I ensure there are IPs left outside the DHCP range that I can assign manually to specific VMs without interference.

#to prevent problems with VM connectivity
$ sudo virsh destroy deb-mongo

$ sudo virsh net-edit default
#I replace this:
<network>
...
  <ip address='192.168.122.1' netmask='255.255.255.0'>
    <dhcp>
---> <range start='192.168.122.2' end='192.168.122.254'/>
    </dhcp>
  </ip>
</network>
#with:
<network>
...
  <ip address='192.168.122.1' netmask='255.255.255.0'>
    <dhcp>
---> <range start='192.168.122.101' end='192.168.122.254'/>
    </dhcp>
  </ip>
</network>

# restart DEFAULT network
$ sudo virsh net-destroy default
$ sudo virsh net-start default

# check that changes came in power:
$ sudo virsh net-dumpxml default
Enter fullscreen mode Exit fullscreen mode

➁.➁ Modifying /etc/network/interfaces

As it was found out earlier, the current IP of deb-mongo VM is 192.168.122.196. To change this IP I will have to modify configurations of network interface stored in /etc/network/interfaces

user-mongo@deb-mongo:~$ sudo vim.tiny /etc/network/interfaces
# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback

# The primary network interface
allow-hotplug enp1s0
iface enp1s0 inet dhcp <--here is what I need to change

# I comment out the line: #iface enp1s0 inet dhcp and replace it with:
iface enp1s0 inet static
Enter fullscreen mode Exit fullscreen mode

With this line (iface enp1s0 inet static), I just switched the mode of interface configuration from dhcp to static, which means I now have to configure it manually. My goal is to assign an IP address of my choice, which can be set using the address field. However, if you were attentive to the previous section, you’d know that DHCP doesn’t just assign an IP address—it also provides the device (the network interface of the VM) with crucial information about the network it’s connecting to. More specifically, it provides details such as the size of the network (netmask) and the IP address of the gateway. Since I switched to static mode, I need to manually provide the following details in the configuration: address, gateway, and netmask.

address 192.168.122.10 is the IP address I want this VM to have persistently. You can choose any number outside the range .

Information about the gateway and netmask is taken from this part of the network configuration:

  <ip address='192.168.122.1' netmask='255.255.255.0'>
    <dhcp>
      <range start='192.168.122.101' end='192.168.122.254'/>
    </dhcp>
  </ip>
Enter fullscreen mode Exit fullscreen mode

Gateway is ip address='192.168.122.1', netmask is netmask='255.255.255.0'. With the netmask, it’s pretty straightforward—it corresponds to the CIDR notation of the network. For example, /24 equals 255.255.255.0, with the last 0 indicating the part of the IP address range that is available for assignment. This defines the size of the network.

But what about the gateway? Remember the previous article's explanation about network switches? In the DEFAULT network, this job is handled by virbr0. All VMs connected to this virtual network switch become part of the same network, and they are allowed to communicate with each other (as per the nftables rules set by libvirt).

It’s not just that by connecting to the same virtual network switch, VMs can magically communicate directly with one another. No, there is a network switch in between EACH COMMUNICATION, acting like a post office. When you want to send a letter to someone, you write the letter, put the destination address on it, and bring it to the post office—you don’t usually travel to the recipient’s address and deliver it manually. The post office handles everything, ensuring the letter is delivered.

The same logic applies to the network switch, and in the case of the DEFAULT network, this role is performed by virbr0, which acts as the gateway. Gateways, as a technical term, are broader than just routers or switches because they can perform a variety of tasks. It is important for the VM to know where to find the gateway in order to communicate via it with other network devices and with the outside world (if allowed). Regardless, the gateway (virbr0) will decide how to handle any communication sent from the VMs connected to it. virbr0 IP address is 192.168.122.1 (can validate with ip a).

This information ends up in the configuration file /etc/network/interfaces:

....
....
#iface enp1s0 inet dhcp
iface enp1s0 inet static
  address 192.168.122.10
  netmask 255.255.255.0
  gateway 192.168.122.1
Enter fullscreen mode Exit fullscreen mode

Then, I restart networking systemd service:

user-mongo@deb-mongo:~$ sudo systemctl restart networking
user-mongo@deb-momgo:~$ ip a
1: lo: 
....
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP ...
    link/ether ...
    inet 192.168.122.10/24 brd 192.168.122.255

# to check if nothing went messed up:
user-mongo@deb-momgo:~$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=112 time=17.4 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=112 time=17.0 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=112 time=16.5 ms
^C
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms

#try to update system packages
user-mongo@deb-momgo:~$ sudo apt update && upgrade
...
Fetched 485 kB in 1s (901 kB/s)
...
All packages are up to date.

#try to ssh from HOST MACHINE
ssh user-mongo@192.168.122.10
...
Are you sure you want to continue connecting (yes/no/[fingerprint])?
#It is requesting you again to accept the fingerprint, because you change "address" of a VM
#Anyway ssh is successful
Enter fullscreen mode Exit fullscreen mode

NB! If you experience any problems with VM's connectivity to the internet, try the following steps:

Destroy the network with sudo virsh net-destroy default;
stop the VM with sudo virsh destroy <vm-name>; restart the libvirtd service with sudo systemctl restart libvirtd; start the network with sudo virsh net-start default; start the VM again with sudo virsh start <vm-name>. Restarted VM should not have problems with connectivity. If connectivity still doesn’t work after these steps, then something got messed up wrong with the configuration.

So now, I should be able to connect to the MongoDB running on my VM deb-mongo from the host using the MongoDB Compass GUI client.

For demonstration purposes, I didn’t configure any sophisticated RBAC for the database, so all I need to connect is the IP address and the port through which the database is accessible. Here is the MongoDB configuration on the VM:

$ user-mongo@deb-mongo:~$ cat /etc/mongof.conf
...
# network interfaces
net:
  port: 27017
  bindIp: 192.168.122.10
...
Enter fullscreen mode Exit fullscreen mode

Voila:

Image description

Now, the objective is to modify configurations of the DEFAULT network in such a way that I can connect to MongoDB on the VM from a laptop that is connected via Wi-Fi to the same local home network as my PC (host).

➂ Connecting to a VM from Another Device on the LAN using the Port Forwarding Method:

This is what Port Forwarding method is about:

Image description

As I’ve pointed out more than once, 192.168.122.10 belongs to a different network than 192.168.1.0/24 (my home's LAN). virbr0 does not bridge any physical interface of my host PC that is connected to the LAN with the DEFAULT network. As a result, the VM’s address is unreachable for any device connected to the LAN.

However, the devices connected to my home's LAN can communicate between themselves.

➂.➀ What is the Port Forwarding?

If you are a developer and work on remote servers using VSCode as IDE, most probably you’ve already used port forwarding indirectly. For example, when you’re developing something like a web app during its early stages, it’s common for the app’s components—backend or frontend—to run on the server’s localhost using specific development ports. However, when you connect to this server via VSCode and update the code, you can see in the browser the preliminary results on your development machine (let's call it laptop), not on the server’s browser.

Here’s the point: let’s say your frontend app is running on the server’s localhost at port 5173. If you try to access it directly from your laptop’s browser, you won’t see anything. That’s because the app is running on the server, not your laptop. Without port forwarding, the server’s localhost is inaccessible to your laptop.

What happens, often with just two clicks—and sometimes even automatically in VSCode—is port forwarding. This forwards the server’s port (e.g., 5173) to a port on your laptop. As a result, you can open the browser on your laptop and see your app at an address like http://localhost:5173. What you’re actually seeing is the server’s localhost:5173, forwarded to your laptop.

So, I’m about to do something similar, bu with port forwarding on the host to the VM deb-mongo. On my host machine, there’s no MongoDB installed, but it is running on the VM. I want to use the host machine as a kind of layover "airport" for the network packets traveling from my laptop to the VM to reach the database (the MongoDB Compass GUI client on my laptop exchanges packets with the MongoDB server and translates the packet payloads into the visualizations I see).

The layover airport analogy fits perfectly here. In real life, layovers are a common practice, and the most interesting parallel is with visas. For instance, let’s say you’re traveling to Brazil from your home country. Based on a bilateral agreement between your country and Brazil, you don’t need a visa to enter Brazil. However, your flight has a layover in the UK, and to enter the UK, you do need a visa. Here’s where it gets interesting: if you’re just transiting through the UK and stay within the airport's transit zone, you don’t need a true UK visa (let's leave aside transition visa). As long as you don’t exit the airport, everything works perfectly. But if you try to leave the transit zone, UK authorities will block you because you’re not permitted to enter UK without a true visa.

Similarly, in networking, my host machine acts as the "layover airport." Packets from my laptop need to travel through this transit point (the host) to reach the MongoDB instance running on the VM. As long as the host is correctly configured to forward packets (like a transit zone in an airport), everything flows smoothly. If, however, the laptop tries to directly access the VM (bypassing the host’s forwarding), the packets will fail because they are not permitted to "enter".

Anyway, why do I speak so much about permissions? Because if you use firewalld or any other firewall that’s up and running, they usually protect your device from unauthorized access. By default, all ports on your host are closed for NEW connections. However, ESTABLISHED and RELATED connections are often allowed, meaning that network packets can navigate through them. ESTABLISHED means the connection was initiated by the device itself (remember the distinction between inbound vs outbound traffic from the previous article?).

What I want to do is use a random port, 12345, on my host machine as a "dummy port" that my laptop can connect via LAN to using the TCP protocol. I want that all network packets sent by my laptop to my host's IP:12345 will be redirected to the VM deb-mongo, specifically to MongoDB’s default port, 27017, and the same for network packets with response from MongoDB.

➂.➁ Firewalld and closed ports

Now, for demonstration purposes, I’ll show you that a connection to this port (12345) on my host is not allowed by firewalld. I’m using the netcat-openbsd package to perform this simulation. It’s installed on both my laptop and my PC (the host).

On the host, I start listening on port 12345. This is just a random listener; there isn’t any service running on this port. It’s not like the case with MongoDB, where MongoDB listens on its default port and expects specific syntax that it can understand. With netcat, I can send any nonsense via TCP, and the listener will still accept it without validation—because it’s just a raw socket tool.

On my host I start a listener on port 12345

$ nc -lv 12345
Listening on 0.0.0.0 12345
Enter fullscreen mode Exit fullscreen mode

From the laptop I try to send a message to the host private IP, port 12345:

$ echo "hellow from the laptop" | nc -v 192.168.1.106 12345
nc: connect to 192.168.1.106 1235 (tcp) failed: No route to host!
Enter fullscreen mode Exit fullscreen mode

The connection fails. I start by investigating the firewalld rules because I’m sure it’s responsible for blocking the connection. And indeed:

$ sudo firewall-cmd --query-port=12345/tcp
no
Enter fullscreen mode Exit fullscreen mode

This confirms that port 12345 is not open (read: no any device can connect to my host via this port). Next, I control which zones are being used for my network interfaces. firewalld applies rules based on zones. However, explaining zones in detail is outside the scope of this article (you can check the documentation here).

$ sudo firewall-cmd --get-active-zones
libvirt
  interfaces: virbr0
public (default)
  interfaces: wlx123456789kl
Enter fullscreen mode Exit fullscreen mode

Not the best idea to modify firewall rules for the public zone, so I switch to the home zone, which is actually the technically correct one because my host is connected to my home's LAN.

$ sudo firewall-cmd --zone=home --change-interface=wlx123456789kl
success
$ sudo firewall-cmd --zone=home --query-port=12345/tcp
no
Enter fullscreen mode Exit fullscreen mode

This port 12345 is also closed in the home zone, so I could just open it with sudo firewall-cmd --zone=home --add-port=12345/tcp. But I want to open it only to be accessible by the private IP address of my laptop. So I just add a rich rule instead.

Remember! Avoid, whenever you can, any wobbly rules for accepting something. Do not open ports to the whole world. Always try to minimize access and add precise, restrictive, and well-defined permissive rules.

$ sudo firewall-cmd --zone=home --add-rich-rule='rule family="ipv4" source address="192.168.1.105" port protocol="tcp" port="12345" accept'
success
Enter fullscreen mode Exit fullscreen mode

And voilà: the message from my laptop successfully arrived at my host, port 12345:

$ sudo nc -lv 12345
Listening on 0.0.0.0 12345
Connection received on LAPTOP-1234567.station XXXXX
hellow from laptop!
Enter fullscreen mode Exit fullscreen mode

NB! If you are unfamiliar with firewalld, you might not have noticed that I didn’t add these rules permanently. This means that when I reboot or reload the firewalld service, these rules will be lost—which is exactly what I wanted.

For port forwarding, the rule is different and doesn’t require keeping the port open on the host which will be forwarded. This is where my earlier example with visas is valid—packets that are simply transiting through my host don’t need permission from the firewall because they don’t actually "enter" my host at the end.

So, I’ve demonstrated and imposed these rules purely for educational purposes, and now I’ll flush them with sudo firewall-cmd --reload, as they’re no longer needed.

➂.➂ Forwarding ports on the host with firewalld

Now, I’ll forward the host’s port 12345 to the VM’s deb-mongo default MongoDB port 27017. I’ll do this by adding a rule with firewalld.

According to the firewalld documentation, this is how port forwarding should be done:

[--permanent] [--zone=zone] [--permanent] [--policy=policy] --add-forward-port=port=portid[-portid]:proto=protocol[:toport=portid[-portid]][:toaddr=address[/mask]] [--timeout=timeval]
Enter fullscreen mode Exit fullscreen mode

As I flushed the rules from the example above, I first need to repeat the process of changing the zone for my WiFi interface from public to home. Once that’s done, I can apply the port forwarding rule. NB! I do not add --permanent option to the commands, but if you plan to keep these firewalld configurations, you will need to specify this option.

$ sudo firewall-cmd --zone=home --change-interface=wlx123456789kl
success
# THE RULE FOR PORT FORWARDING
$ sudo firewall-cmd --zone=home --add-forward-port=port=12345:proto=tcp:toport=27017:toaddr=192.168.122.10
success
Enter fullscreen mode Exit fullscreen mode

So, can I connect now from my laptop to MongoDB running on the VM deb-mongo? No. However, half the job is done.

In the previous article, I described in very detailed way why any inbound traffic is blocked by rules specified in nftables that are placed there by libvirt. For the DEFAULT network, these NAT rules are configured to isolate VMs, allowing them to connect outbound but blocking any inbound traffic from external devices (except Host, of course). This is why the connection still fails.

➂.➃ Adding a rule to nftables ruleset to accept inbound network traffic to the VM

To fix this, I’ll need to adjust these nftables rules. To see ALL the active nftables rules, you can use the command sudo nft list ruleset. To see only rules related to the DEFAULT network, you can specify the table and its family. Table name is libvirt_network, and its family is ip which stands for IPv4 - rules in this table are applicable ONLY to IPv4 network traffic! I prefer to use sudo nft -a list table ip libvirt_network.
The -a option adds a handle to each rule. This handle acts like an index, allowing me to reference and manipulate any specific rule without having to retype the entire rule.

$ sudo nft -a list table ip libvirt_network
...
table ip libvirt_network { # handle 6
    chain forward { # handle 1
        type filter hook forward priority filter; policy accept;
        counter packets 728 bytes 637074 jump guest_cross # handle 7
        counter packets 728 bytes 637074 jump guest_input # handle 5
        counter packets 207 bytes 14639 jump guest_output # handle 3
    }

    chain guest_output { # handle 2
        ip saddr 192.168.122.0/24 iif "virbr0" counter packets 1 bytes 76 accept # handle 55
        iif "virbr0" counter packets 0 bytes 0 reject # handle 52
    }

    chain guest_input { # handle 4
        oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 1 bytes 76 accept # handle 56
        oif "virbr0" counter packets 9 bytes 468 reject # handle 53
    }

    chain guest_cross { # handle 6
        iif "virbr0" oif "virbr0" counter packets 0 bytes 0 accept # handle 54
    }

    chain guest_nat { # handle 8
        type nat hook postrouting priority srcnat; policy accept;
        ip saddr 192.168.122.0/24 ip daddr 224.0.0.0/24 counter packets 0 bytes 0 return # handle 61
        ip saddr 192.168.122.0/24 ip daddr 255.255.255.255 counter packets 0 bytes 0 return # handle 60
        meta l4proto tcp ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 counter packets 0 bytes 0 masquerade to :1024-65535 # handle 59
        meta l4proto udp ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 counter packets 1 bytes 76 masquerade to :1024-65535 # handle 58
        ip saddr 192.168.122.0/24 ip daddr != 192.168.122.0/24 counter packets 0 bytes 0 masquerade # handle 57
    }
}
...
Enter fullscreen mode Exit fullscreen mode

If you’ve already dag all the internet searching for an answer about how to add port forwarding to the DEFAULT libvirt network in NAT mode, or how to use the port forwarding method to access NAT'ed virtual networks, I imagine that your attention is attached to the chain guest_nat in the displayed above libvirt_network table, because it handles NAT.

However, this is misleading! The rule that is actually blocking inbound connections to the VM (deb-mongo, in my case) from a laptop connected to the LAN is not there - the blocking rule is in the chain guest_input!

chain guest_input {
          oif "virbr0" ip daddr 192.168.122.0/24 ct state established,related counter packets 1 bytes 76 accept # handle 56
          oif "virbr0" counter packets 9 bytes 468 reject # handle 53
}
Enter fullscreen mode Exit fullscreen mode

These two rules, #56 and #53, are the culprits behind the issue. Rule #56 accepts ONLY the traffic with destination address in the DEFAULT network 192.168.122.0/24 which is part of ESTABLISHED and RELATED network connections. This is why connections between the host and the VMs work seamlessly, as those are ESTABLISHED or RELATED.
Similarly, outbound traffic from any VM on this network (e.g., downloading packages or connecting to external resources) also falls under this rule.

However, when I try to connect to any VM on this network (192.168.122.0/24) from my laptop, which is on the LAN (a "neighbor" of the host), this traffic is a part of NEW connection. This is where Rule #53 comes into picture and REJECTS any traffic that is part of NEW connections targeting the virtual network devices.

The intuitive solution is to modify rule #56, and using its handle, I can do something like this :

$ sudo nft replace rule libvirt_network guest_input handle 56 oif "virbr0" ip daddr 192.168.122.0/24 ct state new,established,related counter accept
Enter fullscreen mode Exit fullscreen mode

HOWEVER! Remember, it’s wiser to avoid adding overly permissive rules. So, instead, I’ll specify that traffic that is part of NEW connections AND:
A) ONLY from the private IP of my laptop
B) ONLY to the VM deb-mongo
c) ONLY to the port 27017 of the VM deb-mongo
should be accepted, while the rest should fall into rule #53—the rejection rule.

To do this, I need to add a new rule to handle NEW traffic.

Please NOTE! Rule #56, which accepts traffic from ESTABLISHED and RELATED connections, is the first in the chain guest_input. Rule #53, which rejects anything that is not accepted by Rule #56, is the second rule in the chain. If you add a new rule using sudo nft add rule ..., it will be appended to the end of the chain. This means it will never participate in filtering traffic from NEW connections because all these packets will already be rejected by Rule #53. The order of rules in chains matters!

Instead of using sudo nft add rule..., I will use sudo nft insert rule .... This command always inserts the rule at the top of the chain, which is not always ideal for different network logic but, in this case, is completely fine.

$ sudo nft insert rule libvirt_network guest_input \
    oif "virbr0" ip saddr 192.168.1.10 ip daddr 192.168.122.10 tcp dport 27017 \
    ct state new counter accept
Enter fullscreen mode Exit fullscreen mode

VOILA! the connection on my laptop is installed right away after this new rule is added:

Image description

As you may notice, if you are following my steps, nothing needs to be restarted with sudo virsh- nor the DEFAULT network, nor the VM, nor the systemd networking service on the host, nor the same service on the VM. That’s the superpower of network traffic management and mangling.

As I mentioned earlier, virbr0 is not connected in any way to any physical network interfaces of your host. All the connectivity is managed through the nftables ruleset in the libvirt_network table for IPv4 traffic.

➂.➄ Considerable drawbacks of this method

In the end, to enable the Port Forwarding on the NAT'ed DEFAULT network it’s just two commands one: to add the firewalld rule to impose port forwarding on the HOST. Second command is to add a rule to the guest_input chain in the libvirt_netwrok ip table in nftables ruleset.

Quite elegant and simple, no? So, what’s the drawback then, besides the complexity of nftables rules for people completely unfamiliar with them? WELL… the drawback is the persistence of this configuration—or, to be more accurate, zero persistence.

When I added the port forwarding rule with firewalld, I noted that I didn’t make it permanent, but you can preserve the rules by using the --permanent option. What about the nftables rule I added? How do you preserve it? No way.

If you remember, at the start, I showed you (using ip link/ip a outputs) that virbr0 doesn’t exist as an interface before you start the DEFAULT network that is based on it. The same logic applies to the nftables rules related to this virtual interface. If you list all the rules when the DEFAULT network is down, there will be no libvirt_network table in nftables ruleset. But the moment you start the DEFAULT network, the table immediately appears—it is added automatically by libvirt itself.

So, any custom rules for the DEFAULT network you add to nftables will be lost the moment this network stops and restarts. This is the main drawback of this otherwise clean and straightforward approach.

There is a workaround, though—a script that can automate the re-adding of the nftables rule. Even though it’s still a workaround, it is presented in the libvirt documentation on how to forward connections for the DEFAULT network.

However, there’s an important note: the documentation’s example is written for iptables, not nftables.

Here’s a tiny reminder: you cannot just drop iptables rules out of the blue on your Debian system if you have nftables up and running. At best, the rules will simply not work. At worst, you will mess up all the network traffic on your host :3.

Here is this script:

#!/bin/bash

# IMPORTANT: Change the "VM NAME" string to match your actual VM Name.
# In order to create rules to other VMs, just duplicate the below block and configure
# it accordingly.
if [ "${1}" = "VM NAME" ]; then

   # Update the following variables to fit your setup
   GUEST_IP=
   GUEST_PORT=
   HOST_PORT=

   if [ "${2}" = "stopped" ] || [ "${2}" = "reconnect" ]; then
    /sbin/iptables -D FORWARD -o virbr0 -p tcp -d $GUEST_IP --dport $GUEST_PORT -j ACCEPT
    /sbin/iptables -t nat -D PREROUTING -p tcp --dport $HOST_PORT -j DNAT --to $GUEST_IP:$GUEST_PORT
   fi
   if [ "${2}" = "start" ] || [ "${2}" = "reconnect" ]; then
    /sbin/iptables -I FORWARD -o virbr0 -p tcp -d $GUEST_IP --dport $GUEST_PORT -j ACCEPT
    /sbin/iptables -t nat -I PREROUTING -p tcp --dport $HOST_PORT -j DNAT --to $GUEST_IP:$GUEST_PORT
   fi
fi
Enter fullscreen mode Exit fullscreen mode

This workaround is actually a hook script— libvirt's hooks are quite handy for specific system management needs. These hooks can trigger various scripts based on VM-related events like start, shutdown, etc. You can definitely create a similar hook for adding the nftables rule, and it would even be much shorter than this monstrosity of iptables rules.

However, I will not be doing it, nor will I use this configuration for my home lab. That’s because the DEFAULT network is designed for a different purpose—it’s meant to provide a plug-and-play or out-of-the-box experience for new VMs. You create a VM, start it, and everything works from the networking side without additional configuration.

I prefer to keep it that way. For accessibility to the VMs from my LAN devices, I use other network configurations that are better suited for this purpose.

Image of AssemblyAI tool

Transforming Interviews into Publishable Stories with AssemblyAI

Insightview is a modern web application that streamlines the interview workflow for journalists. By leveraging AssemblyAI's LeMUR and Universal-2 technology, it transforms raw interview recordings into structured, actionable content, dramatically reducing the time from recording to publication.

Key Features:
🎥 Audio/video file upload with real-time preview
🗣️ Advanced transcription with speaker identification
⭐ Automatic highlight extraction of key moments
✍️ AI-powered article draft generation
📤 Export interview's subtitles in VTT format

Read full post

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Immerse yourself in a wealth of knowledge with this piece, supported by the inclusive DEV Community—every developer, no matter where they are in their journey, is invited to contribute to our collective wisdom.

A simple “thank you” goes a long way—express your gratitude below in the comments!

Gathering insights enriches our journey on DEV and fortifies our community ties. Did you find this article valuable? Taking a moment to thank the author can have a significant impact.

Okay