Firewalls and DDNS

In the SSH post, I showed a command

sudo ufw allow from AAA.BBB.CCC.DDD to any port ssh proto tcp

to open up access to your Debian based SSH server through ufw. In this tutorial we’ll be using 2 machines: our server which we’re accessing using SSH, and our workstation. It’s pretty simple in the home LAN environment, replace AAA.BBB.CCC.DDD with your workstation’s IP address, like 192.168.1.201, or whatever it is, and you’re done. But what if the server is remote and your workstation’s home ISP only provides you a dynamic IP address? That’s where DDNS comes in.

DDNS, or Dynamic DNS, or DynDNS, is a method of updating the DNS record that points to your network. When your ISP changes your external IP address (when your router reboots, for example), a DDNS client can send the new address to a DDNS provides who updates the DNS record. Some home routers have this functionality built in and they monitor your IP address and update DDNS as needed. But they usually work with a limited number of providers. There are also software clients that you can run on your system, ddclient, for example. And you can run it on any (almost) machine in your network as your router has only one external IP address regardless of the number of internal devices it’s routing to. Let’s not think about the situations with dual WAN, or if you restrict a machine to route all traffic through a VPN. If you’re doing that you probably can figure out how to set DDNS up.

But don’t want to install additional packages and would rather achieve the same result with a help of Bash scripts. We’ll be setting up two scripts. One on the workstation that will keep the DNS record up to date, and another one on the server that will look at the DNS record and update its firewall to allow SSH access only from that IP address. We need the second script as ufw does not work with the domain names but only with the IP addresses. So we’ll need a way to resolve the domain name into the IP and tell ufw to allow that IP.

If you’re going with the implementation on your router, use the services programmed in it. But I will be of no help to you as I don’t know what router you have. Instead, I’ll focus on how to update your DNS record from a Linux machine.

So how do we set DDNS up? First, we need a FQDN, or a Fully Qualified Domain Name, like itsfreeatleast.blog (or itsfreeatleast.com). Since we want it for free, I suggest we use DuckDNS. It’s a free DDNS service that allows you to have up to 5 domains (such as example.duckdns.org). DuckDNS even gives you specific instruction on how to create a script that automatically updates your DNS record here.

Important fields to note on the DuckDNS page (after you login) are the domain and the token, example and abcdef0-1234-5678-9101-112131fedcba, respectively.

Now let’s create a script to update the DuckDNS record. We’ll save the script somewhere your user has access to and name it something like duckdnsupdate.sh. You don’t need to be root to run this script, so the home directory is fine. Adjust the values for the domain <example> and the token <abcdef0-1234-5678-9101-112131fedcba> to the ones shown for you. Use your favorite text editor, I prefer nano.

nano $HOME/duckdnsupdate.sh

Copy and paste the following script:

 #!/bin/bash

DOMAIN = <example>
TOKEN = <abcdef0-1234-5678-9101-112131fedcba>

OLD_IP=$(/usr/bin/host $DOMAIN.duckdns.org | /usr/bin/awk '/has address/ { print $4 }')
if [[ "$OLD_IP" =~ ^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$ ]]; then
    :
else
  /usr/bin/echo "Failed getting Old IP. Using a dummy address."
  OLD_IP="10.111.111.111"
fi
/usr/bin/echo "Old IP is $OLD_IP"

NEW_IP=$(/usr/bin/curl -s --connect-timeout 20 https://checkip.amazonaws.com)
if [[ "$NEW_IP" =~ ^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$ ]]; then
  /usr/bin/echo "New IP is $NEW_IP"
else
  /usr/bin/echo "Failed getting new IP"
  exit 1
fi

if [ "$NEW_IP" = "$OLD_IP" ]; then
  /usr/bin/echo "IP is already correct"
else
/usr/bin/echo url="https://www.duckdns.org/update?domains=$DOMAIN&token=$TOKEN&ip=" | /usr/bin/curl -s --connect-timeout 20 -k -o /tmp/.duckdns/duck.log -K - && /usr/bin/echo "New IP updated!" || /usr/bin/echo "New IP Update Failed!"
fi

exit 0

Exit with CTRL + X, Y to save changes, and Enter.

Now, let’s break it down.

First, we query a DNS server for the IP address of our domain and assign it to a variable OLD_IP with

OLD_IP=$(/usr/bin/host $DOMAIN.duckdns.org | /usr/bin/awk '/has address/ { print $4 }')

Then we check whether the value looks like a legit IP address:

if [[ "$OLD_IP" =~ ^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$ ]];

If it is fine, we continue, if not (it timed out, or connection is down, for example), we use a “dummy” IP address. It can be anything.

Next, we get our external IP address. For that we query https://checkip.amazonaws.com. We use the curl command and we also want to include a connection timeout (20 seconds) and the same validity check as with the OLD_IP.

NEW_IP=$(/usr/bin/curl -s --connect-timeout 20 https://checkip.amazonaws.com)
if [[ "$NEW_IP" =~ ^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$ ]];

If everything goes fine, we assign the IP to a variable NEW_IP, if not, we exit the script.

Then, we compare the OLD_IP (the one the DNS knows) with the NEW_IP (our current external IP). If they match we print "IP is already correct" on the screen and exit. If they don’t match, we send our new IP to DuckDNS and update the record for our domain like so:

/usr/bin/echo url="https://www.duckdns.org/update?domains=$DOMAIN&token=$TOKEN&ip=" | /usr/bin/curl -s --connect-timeout 20 -k -o /tmp/.duckdns/duck.log -K - && /usr/bin/echo "New IP updated!" || /usr/bin/echo "New IP Update Failed!"

This command includes a 20 second timeout and prints out whether the IP update was successful or not. It also places the status update in the file /tmp/.duckdns/duck.log.

Next we’ll make sure only our user owns the file (the chown command) and we’ll the file executable (the chmod command). I also like to tighten down the permissions just a bit.

chown $(id -un):$(id -gn) $HOME/duckdnsupdate.sh
chmod 500 $HOME/duckdnsupdate.sh

Now let’s automate it. We’ll periodically check what the DNS server thinks our IP is, compare it with ours, and update, as needed. I think 15 minute interval is fine, but you can use whatever value you find acceptable. Updating it too frequently places additional burden on the DuckDNS servers, which is not a nice thing to do. Updating it too infrequently will make you wait longer if your external IP updates. We’ll use the good old crontab for this. You can use the systemd timers, but crontab is fine for this.

Use the following command to edit your crontab. It may ask you which text editor to use, again, I prefer nano.

crontab -e

Add the following lines to the bottom (make sure you change <username> to your user, in other words, make sure you use the absolute path to the script duckdnsupdate.sh.

@reboot /usr/bin/sleep 5 && /home/<username>/duckdnsupdate.sh > /dev/null 2>&1
*/15 * * * * /home/<username>/duckdnsupdate.sh > /dev/null 2>&1

Save and exit with CTRL + X, Y, and Enter.

Now, let me explain. The first line tells crontab that after reboot it should wait for 5 seconds and execute the script. The wait is just to ensure the workstation got the internet connection. You may adjust this value. The second line calls the script every 15 minutes. The syntax of crontab is weird, so you can use a generator. > /dev/null 2>&1 is to suppress any text output as we don’t need it.

I prefer to use absolute paths in crontab. It is better for security and here’s why.

If you run the command

echo $PATH

it will show you everything that is contained in the global environmental variable PATH, like so:

/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games

When you call a command, let’s say ls, Linux looks through the paths in the environmental variable PATH for the binary called ls. It does so folder by folder in the order from let to right. On my system, and probably on yours too, ls is located in /usr/bin/ls. You can verify it by running which ls command. So, if a malicious actor is able to access the folder /usr/local/bin and place a malicious file named ls in there, next time you call the ls command, the system will execute /usr/local/bin/ls instead of /usr/bin/ls. The malicious actor can even add folders to the beginning of the PATH variable and store their malicious binaries there. This can lead to privilege escalation and a whole bunch of heartburn. So if you don’t use the absolute variables and the malicious actor performs this type of attack, next time your crontab runs, it will execute the malicious binary. Scary stuff…

OK, we’re done with our workstation. Now it updates the DNS record for example.duckdns.org to our IP address keeps periodic checks for changes.

Now, let head to our server and allow SSH from only our IP address. We’ll do the steps similar to the workstation setup. We’ll create a script file and add it to crontab. We will need to run the script as root, though, because only root can update the firewall parameters. OK, let’s go.

sudo nano /root/ufwallow.sh

Adjust the hostname and the SSH port (default 22) to match your setup. The log file location can stay as is.

#!/bin/bash
HOSTNAME=<example>.duckdns.org
LOGFILE=/root/ufwallowlog
SSH_Port=<22>

Current_IP=$(/usr/bin/host $HOSTNAME | /usr/bin/awk '/has address/ { print $4 }')
if [[ "$Current_IP" =~ ^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$ ]]; then
#    :
  /usr/bin/echo "IP for $HOSTNAME is $Current_IP"
else
  /usr/bin/echo "Failed getting IP"
  exit 1
fi

if [ ! -f $LOGFILE ]; then
    /usr/sbin/ufw allow from $Current_IP to any port $SSH_Port proto tcp
    /usr/bin/echo $Current_IP > $LOGFILE
else

    Old_IP=$(/usr/bin/cat $LOGFILE)
    if [ "$Current_IP" = "$Old_IP" ] ; then
        /usr/bin/echo "IP address has not changed"
    else
        /usr/sbin/ufw allow from $Current_IP to any port $SSH_Port proto tcp
        /usr/sbin/ufw delete allow from $Old_IP to any port $SSH_Port proto tcp
        /usr/bin/echo $Current_IP > $LOGFILE
        /usr/bin/echo "ufw has been updated"
    fi
fi

Exit with CTRL + X, Y to save changes, and Enter.

Here’s the breakdown:

Current_IP=$(/usr/bin/host $HOSTNAME | /usr/bin/awk '/has address/ { print $4 }')
if [[ "$Current_IP" =~ ^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$ ]];

This section gets the current IP address of our workstation using DNS and it checks if the number makes sense. If it does, it assigns it to Current_IP, if not, it exits.

Then it checks if the file /root/ufwallowlog exits and is not empty. Actually, it check the inverse of that, the if statement is true if the file does not exits. This file keeps track of what IP ufw allows SSH access from. If the file does not exit, we just add the rule to ufw.

if [ ! -f $LOGFILE ]; then
    /usr/sbin/ufw allow from $Current_IP to any port $SSH_Port proto tcp
    /usr/bin/echo $Current_IP > $LOGFILE

If it does exit, we read the value from it and compare it to the Current_IP address.

    Old_IP=$(/usr/bin/cat $LOGFILE)
    if [ "$Current_IP" = "$Old_IP" ] ;

If they’re the same, the script just tells us so "IP address has not changed". If it has changed, the script allows access from Current_IP and deletes the rule allowing Old_IP. It then overwrites the log file with the Current_IP for use next time.

/usr/sbin/ufw allow from $Current_IP to any port $SSH_Port proto tcp
/usr/sbin/ufw delete allow from $Old_IP to any port $SSH_Port proto tcp
/usr/bin/echo $Current_IP > $LOGFILE
/usr/bin/echo "ufw has been updated"

After creating the file we’ll make sure it’s owned by root and only root can read and execute it:

sudo chown root:root /root/ufwallow.sh
sudo chmod 500 /root/ufwallow.sh

It’s probably a good idea to make sure only root can write to the log file:

sudo chown root:root /root/ufwallowlog
sudo chmod 600 /root/ufwallowlog

Now, let’s add it to root‘s crontab the instructions to run this script periodically:

sudo nano crontab -e

add to the end:

@reboot /usr/bin/sleep 5 && /root/ufwallow.sh > /dev/null 2>&1
*/15 * * * * /root/ufwallow.sh > /dev/null 2>&1

Again, you can adjust the parameters as you see fit.

And this is it…

OK. It’s a lot of words. But now we have a fully qualified domain name which is being updated from our workstation with our external IP address. Our remote SSH server periodically queries a DNS server to resolve the IP of our workstation and makes sure the IP can access the SSH server. Updates are made as necessary. What I noticed, though, that DuckDNS sometimes is slow to resolve the domains into IP addresses. It happens on some corporate networks. Maybe next time we’ll look at the alternatives. But hey, it’s free, at least…