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:

  1. The existing routing and firewall rules are set with a default DROP policy. These rules are mapped to an nftables chain called FORWARD under the ip table filter, with priority 0.
  2. When the UPnP daemon (MiniUPnPD) starts, it creates a new chain called forward (in lowercase) under the inet table filter, also with a DROP default policy. ip and inet tables in nftables are separate, where ip 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.
  3. 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 all FORWARD traffic, essentially locking out all traffic and interrupting network connectivity. Fortunatelyt, it leaves the INPUT 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 and ip6 chain management of MiniUPnPD was merged into a single inet chain. While his intention of simplifying was good, this creates great difficulty for me, as I have to use separate ip and ip6 chains generated by iptables-nft. Luckily, earlier this year, he added a life-saving config flag that allows the binary to write back to the ip chain instead of the inet chain. This wasn't documented anywhere, and I almost decided to make my own fork of MiniUPnPd until coming across this commit.

Two Approaches to Fix the Problem

  1. 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 the iptables-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 because iptables-nft has been marked deprecated by RHEL), I'll come back and choose this route.
  2. Revise the miniupnpd helper scripts to make it use the ip filter table instead. This is rather hacky, because the ip filter table is managed by iptables-nft and shouldn't be touched. But some quick tests showed that iptables and nft are pretty well-synced. It's feasible to add the miniupnpd chains using nft and then add rules to jump to miniupnpd chains using iptables.

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.