Adding UPnP to My Router | Building a Router from Scratch with Debian, Part 3
2024-11-10
12 min read
This week, I finally had some time and mood for some gaming. I fired up my Xbox, and the first thing I noticed in the settings was a "Moderate" NAT type. How can my perfect router not providing the most ideal networking environment for my console?
Some quick search told me it was because I didn't configure UPnP on my router. To put simply, UPnP enables devices on a local network to communicate with the router and open ports automatically, sparing the hassle of manual configuration. It's critical in gaming, since one of the consoles in a session has to act as the host, allowing other consoles to connect. (But why don't they just move to IPv6?) Somehow XBox thinks UPnP must be enabled to reach an "Open" NAT category.
This guide covers the steps and troubleshooting needed if you want to enable UPnP support on Debian 12. The MiniUPnPD version I am using is 2.3.7-1. Things will definitely change in the future, so adjust as needed.
Installing MiniUPnPD from Debian Testing Repository
miniupnpd
is the widely-accepted UPnP daemon on Linux. It's available on Debian under the package miniupnpd
, with two backends to choose from: miniupnpd-iptables
(the legacy backend that uses iptables-legacy), and miniupnpd-nftables
(the newer default backend that uses nftables). Since I use iptables to configure the routing and firewalling, I should choose miniupnpd-iptables
, right?
Wrong! The iptables backend actually uses the deprecated iptables-legacy kernel module, but the modern iptables package in Debian 11 and newer is iptables-nft
, which is a wrapper for nftables. So I should install only miniupnpd
, which in turn will install the nftables backend by default.
Well, that didn't work either: the stable miniupnpd
Debian packaging is buggy. It will throw a "Cannot fork" error upon startup. The solution is add the Debian testing repository (Trixie) and download the latest version of miniupnpd
from there.
Add the testing repository to /etc/apt/sources.list
:
# Add this line
deb http://deb.debian.org/debian testing main
To avoid installing packages from the testing repository by default, we need to lower the priority of the testing repo. Create the file /etc/apt/preferences.d/99testing
with the following content:
Package: *
Pin: release a=stable
Pin-Priority: 900
Package: *
Pin: release a=testing
Pin-Priority: 400
Fetch the testing repo and install latest version of miniupnpd
(v2.3.7-1
at the moment).
sudo apt update
sudo apt install -t testing miniupnpd
After installation, we need to configure the WAN and LAN interfaces the UPnP service will listen on. Edit /etc/miniupnpd/miniupnpd.conf
and change the following lines:
# WAN network interface
ext_ifname=enp1s0
# LAN network interface
listening_ip=br0
# All the way below, in the deny/allow rule section, add these two lines
allow 1024-65535 192.168.0.0/24 1024-65535
deny 0-65535 0.0.0.0/0 0-65535
Now, let's start the service.
sudo systemctl enable miniupnpd
sudo systemctl start miniupnpd # Oops, what happened to my network??
At this point, I assumed I’d be good to go, but when I started MiniUPnPD, my devices immediately lost access to the Internet, and my router stopped being able to reach its own port 53 for DNS lookups! Stopping MiniUPnPD instantly restored everything, however.
I didn't know there was quite a rabbit hole in front of me.
Why MiniUPnPD Interrupts My Network
The weird behavior is due to the way nftables works.
Let's first go over how nftables work. In short, nftables consists a couple of tables and chains, each with a priority. Packets will be processed by each chain sorted by increasing priority. All chains need to ACCEPT
a packet in order to let it go through. Any DENY
in the processing will result in a discarded packet.
Here's a breakdown of what happened:
- The existing routing and firewall rules are set with a default
DROP
policy. These rules are mapped to an nftables chain calledFORWARD
under theip
tablefilter
, with priority0
. - When the UPnP daemon (
MiniUPnPD
) starts, it creates a new chain calledforward
(in lowercase) under theinet
tablefilter
, also with aDROP
default policy.ip
andinet
tables in nftables are separate, whereip
processes only IPv4 traffic, and in`et is designed to handle both IPv4 and IPv6 traffic. This results in two FORWARD chains with the same priority level. - When chains share the same priority, the processing order is undefined, but each chain must still return an
ACCEPT
for a packet to pass through. Because the UPnP chain is empty upon starting MiniUPnPD, it drops allFORWARD
traffic, essentially locking out all traffic and interrupting network connectivity. Fortunatelyt, it leaves theINPUT
chains intact, so I can still ssh into the router to fix the problem.
To solve this, we need to put the UPnP-related chains under the same table as the routing rules, and perform a jump to the miniupnpd chains at the beginning of routing. Since they are consolidated into the same chain, packets will be accepted as soon as any of them returns ACCEPT
.
There are several discussions on this issue, notably in this GitHub thread. One of the contributors proposed a working pull request that delegates the chain setup logic of MiniUPnPD to the config file and a bunch of scripts. The main executable, on the other hand, only adds and removes port forwarding rules to the chains that are set up and destroyed by the init and teardown scripts. His contribution makes it possible for me to customize the chains to merge into a single table.
However, the contributor did one woeful thing in the same PR: the
ip
andip6
chain management of MiniUPnPD was merged into a singleinet
chain. While his intention of simplifying was good, this creates great difficulty for me, as I have to use separateip
andip6
chains generated byiptables-nft
. Luckily, earlier this year, he added a life-saving config flag that allows the binary to write back to theip
chain instead of theinet
chain. This wasn't documented anywhere, and I almost decided to make my own fork ofMiniUPnPd
until coming across this commit.
Two Approaches to Fix the Problem
- Migrate the damn routing rules to a single nftables
inet
table and ditch iptables altogether. This is the way to go, BUT unfortunately Docker still doesn't support nftables natively, and still uses theiptables-nft
to manage container traffic. And I'm stuck with Docker. If Docker finally supports nftables in the foreseeable future (which shouldn't take long becauseiptables-nft
has been marked deprecated by RHEL), I'll come back and choose this route. - Revise the miniupnpd helper scripts to make it use the
ip
filter
table instead. This is rather hacky, because theip
filter
table is managed byiptables-nft
and shouldn't be touched. But some quick tests showed thatiptables
andnft
are pretty well-synced. It's feasible to add the miniupnpd chains usingnft
and then add rules to jump to miniupnpd chains usingiptables
.
To make it clear, let's see how the nft rules will look like before and after the change.
Before the change:
# Warning: table ip filter is managed by iptables-nft, do not touch!
table ip filter {
chain FORWARD {
type filter hook forward priority 0; policy drop;
jump DOCKER-USER
# Some Docker-related rules
# Routing and firewalling rules
}
chain INPUT {
# Some Docker-related rules
# Routing and firewalling rules
}
}
table ip nat {
chain PREROUTING {
# Docker uses it
}
chain POSTROUTING {
# NAT rule
}
}
table inet filter {
chain forward {
type filter hook forward priority 0; policy drop;
jump miniupnpd # This is responsible for miniupnpd handling, but broken
# miniupnpd wants users to add their custom logic here, but it's impossible in my case
}
chain PREROUTING {
jump miniupnpd-prerouting # Responsible for DNAT
}
chain POSTROUTING {
jump miniupnpd-postrouting # No idea what this is for
}
chain miniupnpd { # To be dynamically managed
}
chain miniupnpd-prerouting { # To be dynamically managed
}
chain miniupnpd-postrouting { # To be dynamically managed
}
}
After the change:
table ip filter {
chain FORWARD {
type filter hook forward priority 0; policy drop;
jump miniupnpd # Jump to miniupnpd chain right here, and skip the rest of the chain if ACCEPTed
jump DOCKER-USER
# Some Docker-related rules
# Routing and firewalling rules
}
chain INPUT {
# Some Docker-related rules
# Routing and firewalling rules
}
# Move miniupnpd chain into the `ip` filter table
chain miniupnpd { # To be dynamically managed
}
}
table ip nat {
chain PREROUTING {
# Move miniupnpd table jump here
jump miniupnpd-prerouting # Responsible for DNAT
# Docker rules
}
chain POSTROUTING {
# Move miniupnpd table jump here
jump miniupnpd-postrouting # No idea what this is for
# NAT rule
}
# Move this chain to `ip` nat table
chain miniupnpd-prerouting { # To be dynamically managed
}
# Move this chain to `ip` nat table
chain miniupnpd-postrouting { # To be dynamically managed
}
}
Fixing the Mess
Okay, here are the fixes I made.
Add systemd unit dependency
Add systemd unit dependency to make miniupnpd boot only after the routing rules are created. This ensures the chains and rules won't be flushed by iptables-nft
.
Edit /usr/lib/systemd/system/miniupnpd.service
and add my routing rule creation service as a dependency.
[Unit]
Description=Lightweight UPnP IGD & PCP/NAT-PMP daemon
Documentation=man:miniupnpd(8)
After=network-online.target iptables-rules.service # <---- Add the dependency here
# And so on
Modify miniupnpd.conf
Modify miniupnpd.conf
again to let the daemon use new nft table names.
# Disable IPv6 (default is no : IPv6 enabled if enabled at build time)
ipv6_disable=yes # I disable this because I don't think IPv6 needs UPnP
# table names for netfilter nft. Default is "filter" for both
upnp_table_name=filter
upnp_nat_table_name=nat # Change this to "nat"
upnp_nftables_family_split=yes # **IMPORTANT**: Uncomment this line and set to yes
Modify MiniUPnPD Helper Scripts
Lastly, We need to modify the three helper scripts. They create the new chains described above using a combination of nft
and iptables
.
# /etc/miniupnpd/miniupnpd_functions.sh
#! /bin/sh
NFT=$(which nft) || {
echo "Can't find nft" >&2
exit 1
}
TABLE="filter" # Change this line
NAT_TABLE="nat" # Change this line
CHAIN="miniupnpd"
PREROUTING_CHAIN="prerouting_miniupnpd"
POSTROUTING_CHAIN="postrouting_miniupnpd"
# The remaining content is left unchanged. I keep it here for the sake of completeness.
while getopts ":t:n:c:p:r:" opt; do
case $opt in
t)
TABLE=$OPTARG
;;
n)
NAT_TABLE=$OPTARG
;;
c)
CHAIN=$OPTARG
;;
p)
PREROUTING_CHAIN=$OPTARG
;;
r)
POSTROUTING_CHAIN=$OPTARG
;;
\?)
echo "Invalid option: -$OPTARG" >&2
exit 1
;;
:)
echo "Option -$OPTARG requires an argument." >&2
exit 1
;;
esac
done
/etc/miniupnpd/nft_init.sh
#!/bin/sh
#
# establish the chains that miniupnpd will update dynamically
#
# 'add' doesn't raise an error if the object already exists. 'create' does.
#
. "$(dirname "$0")/miniupnpd_functions.sh"
echo "Creating nftables structure"
# Assuming table ip filter with chain FORWARD already exists
# Assuming table ip nat with chains PREROUTING and POSTROUTING already exists
# Create $CHAIN chain and add iptables directive to jump to it
nft add chain ip $TABLE $CHAIN
nft flush chain ip $TABLE $CHAIN
iptables -I FORWARD 2 -j $CHAIN # Right after DOCKER-USER
# Create $PREROUTING_CHAIN and $POSTROUTING_CHAIN and add directives to jump to them
nft add chain ip $NAT_TABLE $PREROUTING_CHAIN
nft flush chain ip $NAT_TABLE $PREROUTING_CHAIN
iptables -t $NAT_TABLE -I PREROUTING -j $PREROUTING_CHAIN
nft add chain ip $NAT_TABLE $POSTROUTING_CHAIN
nft flush chain ip $NAT_TABLE $POSTROUTING_CHAIN
iptables -t $NAT_TABLE -I POSTROUTING -j $POSTROUTING_CHAIN
/etc/miniupnpd/nft_removeall.sh
#!/bin/sh
#
# Undo the things nft_init.sh did
#
# Do not disturb other existing structures in nftables, e.g. those created by firewalld
#
. "$(dirname "$0")/miniupnpd_functions.sh"
iptables -C FORWARD -j $CHAIN > /dev/null 2>&1
if [ $? -eq "0" ]
then
echo "Removing iptables rule jumping to $CHAIN"
iptables -D FORWARD -j $CHAIN
fi
$NFT --check list chain ip $TABLE $CHAIN > /dev/null 2>&1
if [ $? -eq "0" ]
then
# Remove the chain
echo "Removing chain $CHAIN"
$NFT delete chain ip $TABLE $CHAIN
fi
iptables -t $NAT_TABLE -C PREROUTING -j $PREROUTING_CHAIN > /dev/null 2>&1
if [ $? -eq "0" ]
then
echo "Removing iptables rule jumping to $PREROUTING_CHAIN"
iptables -t $NAT_TABLE -D PREROUTING -j $PREROUTING_CHAIN
fi
$NFT --check list chain ip $NAT_TABLE $PREROUTING_CHAIN > /dev/null 2>&1
if [ $? -eq "0" ]
then
# Remove the chain
echo "Removing chain $PREROUTING_CHAIN"
$NFT delete chain ip $NAT_TABLE $PREROUTING_CHAIN
fi
iptables -t $NAT_TABLE -C POSTROUTING -j $POSTROUTING_CHAIN > /dev/null 2>&1
if [ $? -eq "0" ]
then
echo "Removing iptables rule jumping to $POSTROUTING_CHAIN"
iptables -t $NAT_TABLE -D POSTROUTING -j $POSTROUTING_CHAIN
fi
$NFT --check list chain ip $NAT_TABLE $POSTROUTING_CHAIN > /dev/null 2>&1
if [ $? -eq "0" ]
then
# Remove the chain
echo "Removing chain $POSTROUTING_CHAIN"
$NFT delete chain ip $NAT_TABLE $POSTROUTING_CHAIN
fi
Cool, we are done. Restart miniupnpd
, and it should be working.
sudo systemctl restart miniupnpd
Testing
I used a Linux laptop to test UPnP is working.
# Create a port forwarding with `upnpc`
$ upnpc -a <my_local_ip> 8000 12345 TCP
upnpc: miniupnpc library test client, version 2.2.8.
(c) 2005-2024 Thomas Bernard.
More information at https://miniupnp.tuxfamily.org/ or http://miniupnp.free.fr/
List of UPNP devices found on the network :
desc: <redacted>
st: urn:schemas-upnp-org:device:InternetGatewayDevice:1
Found valid IGD : <redacted>
Local LAN ip address : <my_local_ip>
ExternalIPAddress = <redacted>
InternalIP:Port = <my_local_ip>:0
external <redacted>:12345 TCP is redirected to internal <my_local_ip>:0 (duration=604800)
$ nc -l -p 8000 -s 0.0.0.0
# Listening to incoming traffic
Then, I make a telnet connection from outside on my VPS.
$ telnet <wan_ip> 12345
# It should connect and the laptop should receive the sent characters.
Lastly, don't forget to remove the port forwarding.
$ upnpc -d 12345 TCP
Another way to test is simply launching my XBox and perform a NAT type test. And yes, it reports an "Open" NAT!
Final Thoughts
Docker, please migrate to nftables ASAP. If you natively supported nftables, I could avoid all these hacks and simply migrate to nftables entirely.