A few weeks ago I started a project to experiment with kvm / libvirt / qemu virtualization on CentOS 8.2. Getting qemu and libvirt all setup was relatively easy, and went pretty painlessly. After that, setting up network bridges went relatively well also.
However, when it got to networking, things got a little less pleasant.
Non-bridged Networking
First and foremost, the project I’m working on treats the hypervisor as the firewall. It does minimal routing to ensure that the VM’s are adequately separated from the main network. Documentation on this was limited, to say the least. I won’t go into the setup details, but you basically create virtual interface bridges via the virsh net-define
from an XML file.
Next, the hard part: creating firewall rules to allow certain services on VM’s to be accessed.
This proved a much more challenging task than I thought. All the information out there made this extremely daunting and non-trivial.
As it turns out, the recommended solutions are comparatively over-engineered to what I’m going to propose.
Setting up the qemu hook
First and foremost: when libvirt
starts, stops, and migrates a VM it calls the qemu
hook which is, by default, in /etc/libvirt/hooks
. There are two parameters for this hook we’re going to care about: ${1}
which is the “domain” or “virtual machine”, and ${2}
which is the “action” being performed.
For our situation, we care about three actions: start
, stopped
, and reconnect
. For reconnect
, we’re simply going to stop
then start
.
VM / Networking Structure
My setup puts all my VM info in /home/kvm
, there are three folders there:
/home/kvm/images
: all my ISO’s;/home/kvm/disks
: all of the disk images for my VM’s;/home/kvm/networks
: all of the scripts for the VM’s of which we should do network operations;
I chose this structure because it’s easy to remember.
Now the biggest problem with libvirt
networking is that it resets firewall rules in iptables
regularly. Any time the daemon restarts, if I recall correctly. As a result, if we create iptables
rules there is no guarantee they will remain after any sort of state-change.
The libvirt hook
So, my solution was to put a network script for each VM in /home/kvm/networks
, named after the VM, that allows me to add/remove the appropriate iptables
rules when the VM starts and stops.
To do so, I created a very simple qemu
hook:
#!/bin/bash
DOMAIN=${1}
ACTION=${2}
BASE_PATH="/home/kvm/networks/$DOMAIN"
if test -f $BASE_PATH; then
if [ $ACTION = "stopped" ] || [ $ACTION = "reconnect" ]; then
$BASE_PATH stopped
fi
if [ $ACTION = "start" ] || [ $ACTION = "reconnect" ]; then
$BASE_PATH start
fi
fi
This hook simply calls /home/kvm/networks/VM_NAME
with either start
or stopped
, depending on what is happening.
Next, we’ll look at a sample VM I have a script for.
Important: after creating this hook, restart
libvirtd
viaservice libvirtd restart
. Otherwise,libvirt
will not pick up on your new script.
The VM Script
The script for each VM is extremely simple, I define the public / external IP, the private / internal IP, and the ports I want to forward, in the format protocol:port
, with commas between each combination:
#!/bin/bash
PUBLIC_IP=192.168.0.2
PRIVATE_IP=10.1.1.2
PORTS=tcp:80,tcp:443
/home/kvm/networks/modify_network $PUBLIC_IP $PRIVATE_IP $PORTS ${1}
The PUBLIC_IP
is an external IP, this IP should be assigned to a physical interface on the server.
We then call another script, /home/kvm/networks/modify_network
, which has the root of the iptables
rules. We also pass ${1}
which is either start
or stopped
(because that’s how qemu
calls it).
The hard part: the iptables rules
Ok, so maybe this isn’t that hard. Again, we have a relatively simple script:
#!/bin/bash
PUBLIC_IP=${1}
PRIVATE_IP=${2}
readarray -d , -t PORTS <<< "${3}"
if [ ${4} = "start" ]; then
/sbin/iptables -I FORWARD -m state -d $PRIVATE_IP/32 --state NEW,RELATED,ESTABLISHED -j ACCEPT
for (( n=0; n < ${#PORTS[*]}; n++ )); do
readarray -d : -t PORT_PARTS <<< "${PORTS[n]//[$'\t\r\n']}"
/sbin/iptables -t nat -I PREROUTING -p ${PORT_PARTS[0]} --dport ${PORT_PARTS[1]} -d $PUBLIC_IP -j DNAT --to $PRIVATE_IP
done
fi
if [ ${4} = "stopped" ]; then
/sbin/iptables -D FORWARD -m state -d $PRIVATE_IP/32 --state NEW,RELATED,ESTABLISHED -j ACCEPT
for (( n=0; n < ${#PORTS[*]}; n++ )); do
readarray -d : -t PORT_PARTS <<< "${PORTS[n]//[$'\t\r\n']}"
/sbin/iptables -t nat -D PREROUTING -p ${PORT_PARTS[0]} --dport ${PORT_PARTS[1]} -d $PUBLIC_IP -j DNAT --to $PRIVATE_IP
done
fi
This one is, perhaps, the most complex so far, but only because I wanted comma-specified ports.
The readarray -d , -t PORTS <<< "${3}"
section reads the third parameter, then splits it on commas into an array called PORTS
.
Then, if the fourth parameter is start
, we do an /sbin/iptables -I FORWARD
insert with the private IP. This is actually extremely important, as libvirt
by default only allows for RELATED,ESTABLISHED
connections by default, and we need to allow NEW
connections as well. I spent probably three or four days fighting this rule specifically.
Next, we use the for
to loop through all the items in the PORTS
array. (The ${#PORTS[*]}
counts the number of elements in the array.) The readarray -d : -t PORT_PARTS <<< "${PORTS[n]//[$'\t\r\n']}"
line does two things: the part of the line after the carets (<<<
) removes whitespace, then the readarray
section again splits the string, this time on colons.
We then do an /sbin/iptables -t nat -I
that opens the protocol and port to the public and private IP combination we had.
Lastly, our stopped
block does the opposite: it removes each rule we previously added to ensure that no errant ports are left open if the VM is killed.
Verify with iptables
Lastly, it’s helpful to verify the iptables -L
result:
Chain FORWARD (policy ACCEPT)
target prot opt source destination
ACCEPT all -- anywhere 10.1.1.2 state NEW,RELATED,ESTABLISHED
ACCEPT all -- anywhere 10.1.1.0/24 ctstate RELATED,ESTABLISHED
ACCEPT all -- 10.1.1.0/24 anywhere
ACCEPT all -- anywhere anywhere
REJECT all -- anywhere anywhere reject-with icmp-port-unreachable
REJECT all -- anywhere anywhere reject-with icmp-port-unreachable
I left the default rules in place, but you’ll notice that the second rule, which allows connections from anywhere
to 10.1.1.0/24
, only allows RELATED,ESTABLISHED
, which means that if you are opening an internal service users won’t be able to connect unless we add our NEW,RELATED,ESTABLISHED
rule, as there’s no previous connection to associate yet.
We’ll also verify the iptables -t nat -L
result:
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DNAT tcp -- anywhere 192.168.0.2 tcp dpt:https to:10.1.1.2
DNAT tcp -- anywhere 192.168.0.2 tcp dpt:http to:10.1.1.2
These both show that our rules were created.
The End
This all is basically the result of my two+ weeks of researching this issue. During my research I came across dozens of articles that had much more complex solutions, but in the end my requirements (NAT’ing specific ports to specific VM’s) actually simplified my solution.