Cover Image

Updating nftables with DDNS

October 9, 2024 - Reading time: 18 minutes

Hobbyist admins and home-labbers sometimes want to make services on a remote host available to machines on a home network. Unfortunately, many residential ISP customers are stuck with dynamic IP addresses, even when they're not behind a CGNAT. This makes securing the remote host difficult. You'd like to configure the remote firewall to allow traffic only from friendly IP addresses, but if one or more of your addresses might change at any time, what can you do? One option is to buy a fixed IP address from your ISP, but that can be costly.

This article describes how you can configure a remote server using nftables to automatically update its firewall rules so that dynamic IP traffic is allowed with minimal downtime when the IP address changes. The solution requires making a small change to your nftables configuration and a short script that runs periodically as a cron job.

Prerequisites

This solution requires that:

  1. The remote server is using nftables as its firewall backend,
  2. You have root (or sudo) access to the remote server, and
  3. You have a working DDNS host name

nftables

nftables is a modern, stateful firewall backend that has largely replaced the legacy iptables as the default firewall in most recent Linux distributions (Debian, Red Hat/CentOS, etc.). If you are using an old distro that still uses iptables, you should consider upgrading as it is being phased out and will eventually be deprecated. The instructions in this article assume that you have nftables installed and configured on the remote server. Information about how to migrate legacy rulesets to nftables can be found on the nftables wiki.

If the remote server is a new installation that doesn't have a firewall configured yet, you can install and configure nftables first from your distribution's repository.

On Debian-based distros:

$ sudo apt update && sudo apt install nftables

On Red Hat-based distros:

$ sudo dnf install nftables

Then ensure nftables is enabled and running:

$ sudo systemctl enable nftables
$ sudo systemctl start nftables

This is a reasonable starter configuration for nftables. It only filters incoming traffic and it limits traffic from all source IP addresses to only the ports specified in the HELLO_WORLD set. (You should modify the elements in this set to include only the ports you actually want exposed to the internet at large.) This is a modified form of the ruleset Simple ruleset for a server from the nftables wiki.

#!/usr/sbin/nft -f

# Flush all rules and start with a clean slate
flush ruleset

table inet filter {
    set HELLO_WORLD {
        type inet_service
        # List of globally-accessible ports
        elements = { 22, 80, 443 }
    }
    chain input {
        # By default, drop all traffic unless it meets a filter
        # criteria specified by the rules that follow below.
        type filter hook input priority 0; policy drop;

        # Allow traffic from established and related packets, drop invalid
        ct state vmap { established : accept, related : accept, invalid : drop }

        # Allow loopback traffic.
        iifname lo accept

        # accept neighbour discovery otherwise connectivity breaks
        icmpv6 type { nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept

        # Allow all traffic for designated ports
        tcp dport @HELLO_WORLD accept
    }
}

If the server doesn't need to expose any ports to entire internet, you can remove the HELLO_WORLD block and rule entirely. The nftables configuration is normally found in /etc/nftables.conf, but may be in a file under the etc/nftables.d/ directory. After creating or changing the configuration, be sure to reload nftables:

$ sudo systemctl reload nftables

DDNS Service

A Dynamic DNS (DDNS) service provider can provide you with a host name and keep its DNS records up-to-date with your current IP address. Host names are usually subdomains of a domain owned by the DDNS service (such as myddns.example.com) but could be a domain or subdomain that you own if you choose to use the service's nameservers. Either way, a client running on your router or machine notifies the DDNS service through an API call whenever your dynamic IP address changes and the service then updates its A record. Most home routers and NAS's have DDNS clients built-in and pre-configured for major DDNS service providers but, if not, the DDNS service you choose will have instructions for running a client on your home machine.

Some ISPs and domain name vendors provide free or paid DDNS services for their customers. In addition, your router or network hardware manufacturer may provide DDNS service as well. For example, ASUS, Netgear, and Synology all offer this service for free. D-Link used to provide it, but discontinued it in 2020.

The most flexible free options, though, will be from dedicated DDNS providers. These services offer free basic DDNS service, often in the hope that you will use their optional paid services (such as VPS or email) when the time comes. Some may offer additional free services, such as SSL certificate registration. There are many free services available, but some of the best-known include:

  • Dynu offers an excellent free DDNS service with up to 4 hostnames using their domains or your own. If you need to install a client, they provide one that can check for IP address changes up to every 2 minutes.
  • No-IP offers each registered user one free DDNS hostname. Users have to update their registration by logging in to their account at least once every 30 days unless they subscribe for US$1.99 per month.
  • Duck DNS is another popular free DDNS provider. They do not provide password logins, so you will need to tie your account to a Google, Twitter, or Github login. Unlike other providers, Duck DNS does not sell any additional services.
  • Afraid.org offers free DDNS service as well as other services at a cost.
  • Cloudflare's free plan, while mainly designed as a web proxy, also includes free DDNS service.

The Solution

Now we need to bridge the DDNS service and our nftables rules so that our DDNS machine is always allowed to access selected services on the remote server. We'll do this in three steps:

  1. Add a rule for the dynamic IP address to the nftables ruleset
  2. Create a bash script that checks and updates the ruleset when the dynamic IP address changes
  3. Create a cron job that runs the script periodically

Add the dynamic IP address to nftables

Add these two set definitions to your nftables configuration at the top of the inet filter (or ip filter) table. (See further below for a complete configuration example.)

set RESTRICTED_PORTS {
    type inet_service
    elements = { your services' ports }
}

set DDNS {
    type ipv4_addr
    elements = { your DDNS host name }
}

The elements in the RESTRICTED_PORTS set should be a comma-separated list of ports that you want open for your DDNS machine, but not open to the rest of the world. For example, to allow access to a MySQL server (port 3306) and a syslog server (port 514), you would use:

elements = { 514, 3306 }

In the DDNS set, you should add the fully-qualified domain name that your DDNS service provided, for example myhost.ddnsfree.com. This tells nftables to look up your latest IP address each time it starts. It won't dynamically update the IP address, but it will always start with the current one.

Next, add this rule to the end of your inet filter input (or ip filter input) chain:

tcp dport @RESTRICTED_PORTS ip saddr @DDNS accept

This rule tells nftables to accept traffic to destination ports listed in RESTRICTED_PORTS set if they come from the source IP address in DDNS.

If you used the example configuration from above, your full configuration will now look something like this (new additions in bold).

#!/usr/sbin/nft -f

# Flush all rules and start with a clean slate
flush ruleset

table inet filter {
    set RESTRICTED_PORTS {
        type inet_service
        elements = { 514, 3306 }
    }
    set DDNS {
        type ipv4_addr
        elements = { myhost.ddnsfree.com }
    }
    set HELLO_WORLD {
        type inet_service
        # List of globally-accessible ports
        elements = { 22, 80, 443 }
    }
    chain input {
        # By default, drop all traffic unless it meets a filter
        # criteria specified by the rules that follow below.
        type filter hook input priority 0; policy drop;

        # Allow traffic from established and related packets, drop invalid
        ct state vmap { established : accept, related : accept, invalid : drop }

        # Allow loopback traffic.
        iifname lo accept

        # accept neighbour discovery otherwise connectivity breaks
        icmpv6 type { nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept

        # Allow traffic from designated ports
        tcp dport @HELLO_WORLD accept
        tcp dport @RESTRICTED_PORTS ip saddr @DDNS accept
    }
}

Reload nftables so that the new sets and rule take effect:

$ sudo systemctl reload nftables

If you list the nftables ruleset now, you should see that the DDNS set has already been populated with your current IP address:

$ sudo nft list ruleset

The next step is to write a simple script that finds your up-to-date IP address and, if necessary, modifies the definition in your nftables rules.

A Bash script to update nftables

Change to your home directory and create a new text file called refreship.sh using nano or your favorite text editor:

$ cd ~
$ nano refreship.sh

Copy the short script below into the editor, replacing your_ddns_host_name with the fully-qualified domain name provided by your DDNS service (for example myhost.ddnsfree.com), then save and exit.

#!/bin/bash
#
# ~/refreship.sh
# Updates nftables if dynamic IP address has changed.
#
# This script must be run as root
#

HOST=your_ddns_host_name
LOOKUP=$( nslookup $HOST | grep -Po "(?<=Address: )\d+\.\d+\.\d+\.\d+" )

if ! [ "$(/usr/sbin/nft get element inet filter DDNS { $LOOKUP } 2>/dev/null)" ]; then
        /usr/sbin/nft flush set inet filter DDNS
        /usr/sbin/nft add element inet filter DDNS { $LOOKUP }
fi

The script, as written, assumes that the nftables executable is located at /usr/sbin/nft and that the nftables table is called inet filter.

Finally, make the script executable:

$ chmod u+x refreship.sh

Now, whenever you run refreship.sh as root, it will look up the current IP address for your DDNS machine and compare it to the last-known IP address in nftables. If the IP address has changed, it will update the nftables rule to reflect the new address. Here's a line-by-line breakdown of what the script does:

HOST=your_ddns_host_name

This sets the variable HOST to your DDNS host name. Replace "your_ddns_host_name with the domain name from your DDNS service (e.g. myhost.ddns.net). This the the only line you need to change.

LOOKUP=$( nslookup $HOST | grep -Po "(?<=Address: )\d+\.\d+\.\d+\.\d+" )

This runs the nslookup shell command to find your DDNS host's current IP address, as set by your provider. The grep command scrapes your current IP address and stores it in the variable LOOKUP.

if ! [ "$(/usr/sbin/nft get element inet filter DDNS { $LOOKUP } 2>/dev/null)" ]; then

This line checks your nftables configuration to see if it already knows about the current IP address. (Output of the command is discarded since we'll be running this script as a cron job.) If nftables already knows about this IP address, then we're done. Otherwise, your IP address has changed, so we'll execute the next two lines:

        /usr/sbin/nft flush set inet filter DDNS

This line, only run if your IP address has changed, clears the old address from nftables.

        /usr/sbin/nft add element inet filter DDNS { $LOOKUP }

This line, only run if your IP address has changed, writes the new address into nftables.

fi

End the if block.

All that's left now is to create a cron job so that refreship.sh automatically runs as often as you like. You will need to make an entry in the root crontab (not your own), so invoke the crontab editor using sudo:

$ sudo crontab -e

At the bottom of the crontable, you can add this entry to run refreship.sh every 15 minutes. (Choose a shorter interval if you like.) Replace your_username with your own login name. You should not use ~/refreship.sh because the script will run as root, which has /root as it's home.

*/15 * * * * /home/your_username/refreship.sh

This entry tells cron to run refreship.sh every 15 minutes of every hour of every day on every day of the week during every month. Save and exit the crontab editor and it will begin running immediately.

Now, your nftables rule will automatically adapt when your IP address changes with just a few minutes of transition time. Depending on your ISP, you can probably force an IP change for testing by cycling the power on your router or modem, waiting the number of minutes you specified in the cron job, and checking the nftables ruleset with sudo nft list ruleset.

The only downside to this solution is that there is a short lag between when your IP address changes and when the change is reflected in your firewall. If you are running a web server or API server on the remote host, you can reduce the lag by writing an API that responds immediately, but that's a more complicated solution that I may cover in a future article.

firewallcybersecuritynftablesddnsdynamic dns


"All the Grues That Fit, We Print."


The door opens, and nineteen demons, each a cross between a carrot and a sledge hammer, march out from behind it, knock you senseless, and return, the last closing the door behind it.