Updating UFW with a DDNS

Intro

Why would you want to do this? Well imagnie that you need to allow only a certain IP through a firewall for SSH access to your server, you could just set the IP and be done with it, but what happens if you have a dynamic IP instead of a static one? You will eventually lose access to your server and you’ll be shit out of luck until you can get back on it locally. This guide will show you how to update UFW through Python and set it to systemd to update every hour.

Prereqs

This guide assumes you have basic knowledge of the Python programming language, but I will go ahead and explain things as we go along. You will need a server with the following installed:

  • Python3
  • UFW
  • Systemd
  • Nano

I will assume you are using Arch Linux so you can do the following to install these packages:

sudo pacman -S python ufw nano

Python Script

So first things first, go ahead and open a new file for our python program, for the purposes of this guide I will store the file in /usr/local/bin/. You can open a new file with nano like the following: sudo nano /usr/local/bin/ufw_ddns

Now that we got the file open in our nano editor, we can go ahead and start writing some code.

First we will go ahead and take stock of the libraries we’ll be needing.

  • socket -> allows us to turn our DDNS address to a IP
  • subprocess -> allows us to write changes to UFW through the shell
  • shlex -> allows us to use our command in subprocess as one big string
#!/usr/bin/env python3
import subprocess
import shlex
import socket

Let’s get our IP address from our DDNS.

def get_current_ip():
    return socket.gethostbyname("your.ddns.address.io")

This function will return our current IP at the above DDNS address, which we will use to compare to the UFW rule that is associated with port 22.

def get_old_ip():
    cmd = subprocess.Popen(["sudo ufw status"], stdout=subprocess.PIPE, shell=True)
    out,err = cmd.communicate()
    out = out.decode("UTF-8").split("\n")
    for line in out:
        if "22" in line:
            ip = list(filter(None, line.split(" ")))
            return ip[2]
    return ""

This function has a couple things going on, first we open a shell with our subprocess library and then send our STDOUT to a variable called cmd.

Then we cmd.communicated() which will return our standard output to a new variable and distinguishes between regular output and error output.

Our standard out isn’t decoded from UTF-8 so we need to go ahead and do that with the .decode("UTF-8") function, we then split the decoded output by newlines, splitting basically takes each line that is outputted, looks for the input in the string it’s given, in our case it’s "\n" which is just fancy for saying after every NEW line, split will then give us a python list with every element being a new line.

We need to loop through our list which is now at out so we can read every line that UFW gave us, and we’re looking for 22, because that is the port that SSH is listening on and the port we want to change if our IP updated.

If we do find that line, we go ahead and split the line we got back by whitespace, and then we filter all the NONE out and we return the third element of that variable since that would give us back our old IP. You can change return ip[2] to print(ip) if you want to inspect the list that was outputted and see why we got the third element.

Cool, we have the old IP and the new IP. Let’s go ahead and compare them to see if we need to update UFW or not.

def compare():
    current_ip = get_current_ip()
    old_ip = get_old_ip()
    
    if current_ip == old_ip:
        print("IP has not changed.")
        return
    else:
        print(f"IP has changed. Old: {old_ip} => Current: {current_ip}")
        if old_ip != "":
            subprocess.run(shlex.split(f"sudo ufw delete allow from {old_ip} to any port 22"))
        subprocess.run(shlex.split(f"sudo ufw allow from {current_ip} to any port 22"))

We call our previous functions and put their return outputs to variables current_ip and old_ip. If we see that the current_ip is EQUAL to the old_ip we just go ahead and return, because nothing has changed.

But if the IPs are different, we need to change it, and we’ll go ahead and look at the old_ip to make sure it’s actually has a value.

If the old_ip has a value, we first delete that IP from our UFW, shlex in this statement just splits our long string into individual strings for each command, we need to use this for subprocess.run but we could have also done something like this: ["sudo", "ufw", "delete]" etc, I find it easier to just use shlex.

Now that we have determined that the old_ip has been deleted we just allow it with subprocess from our current_ip

And we’re done with the python code! Take a look at our finished product here:

#!/usr/bin/env python3
import subprocess
import shlex
import socket

def get_current_ip():
    return socket.gethostbyname("your.ddns.address")


def get_old_ip():
    cmd = subprocess.Popen(["sudo ufw status"], stdout=subprocess.PIPE, shell=True)
    out,err = cmd.communicate()
    out = out.decode("UTF-8").split("\n")
    for line in out:
        if "22" in line:
            ip = list(filter(None, line.split(" ")))
            return ip[2]
    return ""


def compare():
    current_ip = get_current_ip()
    old_ip = get_old_ip()
    
    if current_ip == old_ip:
        print("IP has not changed.")
        return
    else:
        print(f"IP has changed. Old: {old_ip} => Current: {current_ip}")
        if old_ip != "":
            subprocess.run(shlex.split(f"sudo ufw delete allow from {old_ip} to any port 22"))
        subprocess.run(shlex.split(f"sudo ufw allow from {current_ip} to any port 22"))


def main():
    compare()


if __name__ == "__main__":
    main()

Now we can go ahead and save the script by pressing ctrl+x on our keyboard for nano, and then hitting enter and y, that will save the file to /usr/local/bin/ufw_ddns

Systemd Service

We need this to update periodically to check if our IP has changed, we can do this with either cron or systemd. Since arch doesn’t natively have cron I opted for systemd.

Go ahead and make a new service file like the following: sudo nano /etc/systemd/system/ufw_ddns.service and enter the following information into it:

[Unit]
Description=UFW DDNS
After=network.target

[Service]
Type=oneshot
ExecStart=python3 /usr/local/bin/ufw_ddns
StandardOutput=journal

[Install]
WantedBy=multi-user.target

Save this file like we did before with nano, and make a timer file like the following: sudo nano /etc/systemd/system/ufw_ddns.timer and enter the following into it:

[Unit]
Description=Run UFW DDNS every 1h

[Timer]
OnBootSec=10s
OnUnitActiveSec=1h

[Install]
WantedBy=timers.target

Cool, save the file and then reload our systemd daemon: sudo systemctl daemon-reload

Go ahead and enable and star the timer systemd process: sudo systemctl restart ufw_ddns.timer, sudo systemctl enable ufw_ddns.timer

And we’re all done!

Conclusion

UFW will now update to our IP address specified by our ddns and allow us to use port 22 however we please if we are on that IP.