56

I run a VPS which I would like to secure using UFW, allowing connections only to port 80. However, in order to be able to administer it remotely, I need to keep port 22 open and make it reachable from home.

I know that UFW can be configured to allow connections to a port only from specific IP address:

ufw allow proto tcp from 123.123.123.123 to any port 22

But my IP address is dynamic, so this is not yet the solution.

The question is: I have dynamic DNS resolution with DynDNS, so is it possible to create a Rule using the domain instead of the IP?

I already tried this:

ufw allow proto tcp from mydomain.dyndns.org to any port 22

but I got ERROR: Bad source address

9 Answers 9

71

I don't believe this is possible with ufw. ufw is just a frontend to iptables which also lacks this feature, so one approach would be to create a crontab entry which would periodically run and check if the IP address has changed. If it has then it will update it.

You might be tempted to do this:

$ iptables -A INPUT -p tcp --src mydomain.dyndns.org --dport 22 -j ACCEPT

But this will resolve the hostname to an IP and use that for the rule, so if the IP later changes this rule will become invalid.

Alternative idea

You could create a script like so, called, iptables_update.bash.

#!/bin/bash
#allow a dyndns name

HOSTNAME=HOST_NAME_HERE
LOGFILE=LOGFILE_NAME_HERE

Current_IP=$(host $HOSTNAME | cut -f4 -d' ')

if [ $LOGFILE = "" ] ; then
  iptables -I INPUT -i eth1 -s $Current_IP -j ACCEPT
  echo $Current_IP > $LOGFILE
else

  Old_IP=$(cat $LOGFILE)

  if [ "$Current_IP" = "$Old_IP" ] ; then
    echo IP address has not changed
  else
    iptables -D INPUT -i eth1 -s $Old_IP -j ACCEPT
    iptables -I INPUT -i eth1 -s $Current_IP -j ACCEPT
    /etc/init.d/iptables save
    echo $Current_IP > $LOGFILE
    echo iptables have been updated
  fi
fi

source: Using IPTables with Dynamic IP hostnames like dyndns.org

With this script saved you could create a crontab entry like so in the file /etc/crontab:

*/5 * * * * root /etc/iptables_update.bash > /dev/null 2>&1

This entry would then run the script every 5 minutes, checking to see if the IP address assigned to the hostname has changed. If so then it will create a new rule allowing it, while deleting the old rule for the old IP address.

8
  • 3
    How silly that I didn't think about resolving the hostname periodically. I modified your script (added logging, etc.) and it works like a charm. Thank you! Sep 21, 2013 at 14:42
  • @CarlesSala - glad it solved your issue. In addition to accepting you can also upvote 8-).
    – slm
    Sep 21, 2013 at 14:48
  • 1
    note: on Debian 7 I had to change line Current_IP=$(host $HOSTNAME | cut -f4 -d' ') to Current_IP=$(host $HOSTNAME | head -n1 | cut -f4 -d ' ')
    – Krystian
    Jan 6, 2016 at 13:47
  • Will I be able to see this when using ufw status verbose ? I mean, the rules?
    – Freedo
    Apr 7, 2017 at 9:02
  • @Freedo not sure, try it and see what happens.
    – slm
    Apr 7, 2017 at 11:16
9

I know this is old but I ran across it and ended up with this solution in the end which seems even better because no log file is needed and it very easy to add additional hosts as needed. Works like a charm!

Source: http://rdstash.blogspot.ch/2013/09/allow-host-with-dynamic-ip-through.html

#!/bin/bash

DYNHOST=$1
DYNHOST=${DYNHOST:0:28}
DYNIP=$(host $DYNHOST | grep -iE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" |cut -f4 -d' '|head -n 1)

# Exit if invalid IP address is returned
case $DYNIP in
0.0.0.0 )
exit 1 ;;
255.255.255.255 )
exit 1 ;;
esac

# Exit if IP address not in proper format
if ! [[ $DYNIP =~ (([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]) ]]; then
exit 1
fi

# If chain for remote doesn't exist, create it
if ! /sbin/iptables -L $DYNHOST -n >/dev/null 2>&1 ; then
/sbin/iptables -N $DYNHOST >/dev/null 2>&1
fi

# Check IP address to see if the chain matches first; skip rest of script if update is not needed
if ! /sbin/iptables -n -L $DYNHOST | grep -iE " $DYNIP " >/dev/null 2>&1 ; then


# Flush old rules, and add new
/sbin/iptables -F $DYNHOST >/dev/null 2>&1
/sbin/iptables -I $DYNHOST -s $DYNIP -j ACCEPT

# Add chain to INPUT filter if it doesn't exist
if ! /sbin/iptables -C INPUT -t filter -j $DYNHOST >/dev/null 2>&1 ; then
/sbin/iptables -t filter -I INPUT -j $DYNHOST
fi

fi
2
  • 1
    sorry I'm a little newbie. Where do I need to store this script and where I change things to reflect my specific case ?
    – Freedo
    Apr 7, 2017 at 9:52
  • 1
    @Freedo I googled "where to store bash scripts." For all users its: /usr/local/bin. So do a allow_host_with_dynamic_ip.sh. That creates a file. Then do ls /usr/local/bin to see the file listed. Now do vim /usr/local/bin/allow_host_with_dynamic_ip.sh. Edit it and make sure replace / insert mode is set by hitting insert key. Write the file by first escaping (esc key) out of edit mode and then typing :w. Quit with, you guessed it :q. That will bring you back to your command session. Jan 13, 2021 at 4:36
9

Based on previous answers I updated the following as bash script that works on Debian Jessie

#!/bin/bash
HOSTNAME=dynamichost.domain.com
LOGFILE=$HOME/ufw.log
Current_IP=$(host $HOSTNAME | head -n1 | cut -f4 -d ' ')

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

    Old_IP=$(cat $LOGFILE)
    if [ "$Current_IP" = "$Old_IP" ] ; then
        echo IP address has not changed
    else
        /usr/sbin/ufw delete allow from $Old_IP to any port 22 proto tcp
        /usr/sbin/ufw allow from $Current_IP to any port 22 proto tcp
        echo $Current_IP > $LOGFILE
        echo iptables have been updated
    fi
fi
5
  • That could even be added to cron to have it run periodically on it's own. Mar 21, 2017 at 15:18
  • That's what I did ;) Mar 22, 2017 at 14:39
  • This script has a small problem: on first usage if you forgot to run as root, it'll create the log file but not add the rules. Then if you run again as root it'll just say 'ip address didn't change'. It has to be run as root the first time! Also, would be good to change LOGFILE=$HOME/ufw.log to LOGFILE=$HOME/ufw.$HOSTNAME.log to permit more than one script run at the same time Apr 25, 2018 at 2:38
  • @GuerlandoOCs how do you reset if you run into this issue?
    – Matthew
    Dec 28, 2018 at 15:14
  • Possible issue when setting Current_IP: If the hostname is given by a DNS CNAME record (basically, an alias for another DNS name), then the IP address is not in the first line of host's output. Solution: Replace head -n1 with tail -n1.
    – chris6953
    Apr 13, 2020 at 6:36
6

Based on all answers before I combined them. No logfile needed. Tested on Ubuntu 18.04

#!/bin/bash
HOSTNAME=YOUR.DNS.NAME.HERE

if [[ $EUID -ne 0 ]]; then
   echo "This script must be run as root"
   exit 1
fi

new_ip=$(host $HOSTNAME | head -n1 | cut -f4 -d ' ')
old_ip=$(/usr/sbin/ufw status | grep $HOSTNAME | head -n1 | tr -s ' ' | cut -f3 -d ' ')

if [ "$new_ip" = "$old_ip" ] ; then
    echo IP address has not changed
else
    if [ -n "$old_ip" ] ; then
        /usr/sbin/ufw delete allow from $old_ip to any
    fi
    /usr/sbin/ufw allow from $new_ip to any comment $HOSTNAME
    echo iptables have been updated
fi

You can add a port to the rules with "port" parameter. e.G.:

if [ -n "$old_ip" ] ; then
    /usr/sbin/ufw delete allow from $old_ip to any port 22
fi
/usr/sbin/ufw allow from $new_ip to any port 22 comment $HOSTNAME
1
  • 1
    You need to install apt-get install dnsutils
    – mr_squall
    Jun 23, 2020 at 10:25
1

Here is a version in python which can add or remove ipv4 and ipv6 rules if the hostname resolves to multiple endpoints (ufw). Note that my scenario was slightly different as I started with an "Allow everything" profile.

Based on the version from Tim Kennedy and Mattias Pettersson

#!/usr/bin/env python

# Only allow a particular HOSTNAME to access the given port...

# from https://unix.stackexchange.com/a/534117/66983
# and https://unix.stackexchange.com/a/91711/66983
# If the ufw table is empty you might need to execute the script twice (as inserting on top will not work properly)
# crontab -e and add '*/5 * * * * root /path/to/update_ufw.py > /dev/null 2>&1'
HOSTNAME="<hostname>"
PORT=<port>

import os
import subprocess

if os.geteuid() != 0:
    print("This script must be run as root")
    exit(1)

def run(cmd):
    process = subprocess.Popen(['bash', '-c', cmd],
                     stdout=subprocess.PIPE)
    stdout, stderr = process.communicate()
    return stdout.decode('utf-8')

new_ip_output = run("getent ahosts \"{}\" | awk '{{ print $1 }}'".format(HOSTNAME))
new_ips=set(new_ip_output.split())
old_ip_output = run("/usr/sbin/ufw status | grep {} | head -n1 | tr -s ' ' | cut -f3 -d ' '".format(HOSTNAME))
old_ips=set(old_ip_output.split())


if old_ips == new_ips:
    print ("All IPs still OK.")
else:
    # add new IPs
    for new_ip in new_ips:
        if new_ip not in old_ips:
            out = run("/usr/sbin/ufw insert 1 allow from {} to any port {} comment {}".format(new_ip, PORT, HOSTNAME))
            print(out)
    
    # remove old IPs
    for old_ip in old_ips:
         if old_ip not in new_ips:
            out = run("/usr/sbin/ufw delete allow from {} to any port {}".format(old_ip, PORT))
            print(out)
    
    # add deny rule
    out = run("/usr/sbin/ufw deny {}".format(PORT))
    print(out)

2
  • Genius move by switching from bash to Python. I was fine typing out a shell script, but now I'll just copy / paste your python and go through it line by line. You have very clean coding skills. I hope your rep goes much higher, so I've upvoted. Question is, how do I now delete the .sh script... Jan 13, 2021 at 4:42
  • Ahah! You cd /usr/local/bin and rm myfile.sh. Jan 13, 2021 at 4:45
1

If you have multiple hosts for same DNS, you can use this script.

#!/bin/bash
# 1. hostname
# How to use:
# ./ufw unix.stackexchange.com 5432

declare HOSTNAME=$1
declare PORT=$2
declare OLD_HOSTS_FILE=$HOME/$HOSTNAME.$PORT.backup
declare OLD_HOSTS_CONTENT=$(cat $OLD_HOSTS_FILE)
declare NEW_HOSTS_CONTENT=$(getent hosts $HOSTNAME | awk '{ print $1 }' | sort)

# Check if hosts is equals
declare OLD_HOSTS_CONTENT64=$(echo $OLD_HOSTS_CONTENT | base64)
declare NEW_HOSTS_CONTENT64=$(echo $NEW_HOSTS_CONTENT | base64)
if [ "$OLD_HOSTS_CONTENT64" == "$NEW_HOSTS_CONTENT64" ] ; then
  echo IP address has not changed
  exit 
fi

# Remove old hosts
for Old_IP in "${HOSTS[@]}"
do
  echo Remove old host $Old_IP
  /usr/sbin/ufw delete allow from $Old_IP to any port $PORT proto tcp
done


# Add new hosts
declare -a HOSTS=($NEW_HOSTS_CONTENT)
for Current_IP in "${HOSTS[@]}"
do
  echo Add host $Current_IP
  /usr/sbin/ufw allow from $Current_IP to any port $PORT proto tcp
done
echo $NEW_HOSTS_CONTENT > $OLD_HOSTS_FILE

crontab:

*/5 * * * * root /etc/ufw_config.bash unix.stackexchange.com 22 > /dev/null 2>&1
*/5 * * * * root /etc/ufw_config.bash example.com 22 > /dev/null 2>&1
1
  • 1
    Thanks @AdminBee
    – David
    Nov 10, 2021 at 1:34
1

Instead of a script to update the IP rule, you can use port knocking, which will allow an specific source address after an specific sequence of ports have been blocked by the firewall.

In my experience the sequence won't unlock at the first try (depending of the traffic going on). So I made this script to simplify the task (let's call it sshk.sh):

#!/bin/bash
SSHPORT=$1
USER=$2
SERVER=$3
if [[ $3 = "" ]]; then
    echo "Usage: $0 [PORT] [USER] [SERVER] [SEQ1 2 3 ...]";
    exit;
fi
echo "Connecting..."
OPEN=$(nmap -p$SSHPORT $SERVER | grep " open ")
while [[ $OPEN == "" ]]; do
    knock $SERVER "${@:4}"
    OPEN=$(nmap -p$SSHPORT $SERVER | grep " open ")
    if [[ $OPEN == "" ]]; then
        echo -n "."
        sleep 1;
    fi
done
echo "Done.";
ssh -p$SSHPORT $USER@$SERVER

With this script, if the port is already open, it won't knock again.

And in your VPS, install knockd and edit /etc/knockd.conf. This is my config (which will use UFW and it will remove the rule automatically after 1 hour, to prevent leaving garbage in your firewall rules):

[options]
    UseSyslog
    LogFile = /var/log/knockd.log

[allowUFW]
    sequence        = 7007,1457,3939,924,2022
    seq_timeout     = 15
    start_command   = ufw_from + %IP%
    cmd_timeout     = 3600
    stop_command    = ufw_from - %IP%
    tcpflags        = syn

In your home terminal you execute (you can create another script or an alias to simplify):

./sshk.sh 22 [email protected] 7007 1457 3939 924 2022

Important Notes:

  • The longer the sequence of numbers, the longer it will take to succeed.
  • Try to keep the sequence from 3 to 5 numbers (under 3, it is not very secure, above 5 may take too long).
  • Do not use consecutive numbers (as scanners may probe in sequence)
  • You can use shorter cmd_timeout setting as once you login, it will keep you logged even when the rule is reverted. I use 1 hour, as I may want to keep logging in during that time.
  • It is better not to use the default SSH port
  • Use public key with password for stronger security

More about it:

https://www.tecmint.com/port-knocking-to-secure-ssh/

https://www.howtogeek.com/442733/how-to-use-port-knocking-on-linux-and-why-you-shouldnt/

1
  • 1
    Very insteresting approach, this port knocking. Thanks for sharing! Jun 10, 2022 at 10:58
0

I based on the last comment of Sebastian, but for multiple ports, and added an IP check that I found on the web. Since duckdns sometimes crashes and servers don't respond with the IP. host == "Found"

This works for me.

#!/bin/bash
function valid_ip()
{
    local  ip=$1
    local  stat=1

    if [[ $ip =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
        OIFS=$IFS
        IFS='.'
        ip=($ip)
        IFS=$OIFS
        [[ ${ip[0]} -le 255 && ${ip[1]} -le 255 \
            && ${ip[2]} -le 255 && ${ip[3]} -le 255 ]]
        stat=$?
    fi
    return $stat
}

HOSTNAME=YOUR.DNS.NAME.HERE
if [[ $EUID -ne 0 ]]; then
   echo "This script must be run as root"
   exit 1
fi

new_ip=$(host $HOSTNAME | head -n1 | cut -f4 -d ' ')
old_ip=$(/usr/sbin/ufw status | grep $HOSTNAME | head -n1 | tr -s ' ' | cut -f3 -d ' ')

if ! valid_ip $new_ip;
then
   exit 1
fi


if [ "$new_ip" = "$old_ip" ] ; then
    exit 0
else
    if [ -n "$old_ip" ] ; then
        /usr/sbin/ufw delete allow from $old_ip to any port 22,6556 proto tcp
    fi
    /usr/sbin/ufw allow from $new_ip to any port 22,6556 proto tcp comment $HOSTNAME
    echo "iptables have been modified by change of ip $new_ip"
    exit 0
fi
4
  • (1) Please don’t post partial answers.  Each answer should be free-standing, self-contained and self-sufficient.  This doesn’t make much sense unless we look at it in conjunction with slm’s answer. (2) As I said, your answer obviously builds on slm’s answer, and you say that you found part of your answer on the web.  You should identify any posts that you build on or copy from, linking to them and identifying their authors. (3) You modified the answer to allow multiple ports.  Why?  … (Cont’d) May 21, 2020 at 3:12
  • (Cont’d) …  The question explicitly calls for port 22; so, in that regard, you aren’t answering the question. (4) Most of Stack Exchange works in English.  Please don’t post text in other languages. May 21, 2020 at 3:12
  • No, my answer is based on the last comment, which is from "Sebastian".
    – Evaristo
    May 21, 2020 at 23:33
  • Sorry. I added the references you were saying, and corrected the little phrase in Spanish. As for adding more ports, it just solved a need. I thought it provided extra value. Sorry for the inconvenience.
    – Evaristo
    May 21, 2020 at 23:35
0

In this example which is a bit more complex, we are using multiple domains listed in a text file domains.txt to build a whitelist of IPs, and we prevent IPs from being deleted from UFW whitelist until 300 seconds after they were last observed in a DNS result.

The syntax of my ufw command is somewhat different as I have slightly different use case of allowing the traffic to route across specific interfaces.

I set this script to run at boot time in rc.local and it does a good job of maintaining the UFW with the whitelist, with minimal CPU consumption.

I should note that this solution is not really scalable to more than 100-200 domains because of CPU load of ufw command and the latency required for each ufw command, and for more than this number of domains (and probably less too) we should probably write the iptables rules into /etc/ufw/user.rules manually, and then "ufw reload".... which would likely be a much more scalable approach.

It generates alot of DNS traffic, so best to use an internal caching DNS server (e.g. Pihole, etc.) and point the host towards it.

#!/bin/bash
declare -A ip_whitelist_lastseen_times
while :
do
  echo "Reading domains..."
  DOMAINS=$(cat domains.txt)
  rm ip_whitelist.txt
  # Generate whitelist based upon DNS query
  echo "Generating whitelist..."
  for DOMAIN in ${DOMAINS[@]}
  do
    host $DOMAIN | grep 'has address' | cut -f4 -d ' ' >> ip_whitelist.txt
  done
  echo "Whitelist generated, now we will sort it and remove non-unique entries. "
  cat ip_whitelist.txt | sort | uniq > ip_whitelist_unique.txt

  echo "Indexing whitelist into array"
  # Read the whitelist into array.
  unset new_ip_list
  IFS=$'\n' read -r -d '' -a new_ip_list < <( cat ip_whitelist_unique.txt && printf '\0' )

  echo "Reading list of currently loaded rules."
  # Get the UFW current list of IPs with WHITELIST description
  # Read the existing entries, into an array.
  unset old_ip_list
  IFS=$'\n' read -r -d '' -a old_ip_list < <( /usr/sbin/ufw status | grep "WHITELIST" | tr -s ' ' | cut -f1 -d ' ' && printf '\0' )

  echo "resetting and Flipping array of whitelist"
  # Flipping array, so it can be searched by index
  # -searching array by index is faster than looping through the
  #   same array in bash repeatedly for every new/old entry
  #   and then making same comparisons repeatedly
  unset new_ip_list_index
  declare -A new_ip_list_index
  for new_ip in "${new_ip_list[@]}"
  do
    new_ip_list_index[$new_ip]=1
  done

  echo "resetting and flipping array of currently loaded rules"
  unset old_ip_list_index
  declare -A old_ip_list_index

  for new_ip in "${old_ip_list[@]}"
  do
    old_ip_list_index[$new_ip]=1
  done

  #: for each new IP, check if it is in the existing list of IPs
  #: If it is not, then we need to add a rule

  echo "iterating to add rules"
  for new_ip in "${new_ip_list[@]}"
  do
    if [ ! -v "old_ip_list_index[$new_ip]" ] ; then
      /usr/sbin/ufw route allow in on enp5s0f0 out on enp5s0f1 from 192.168.0.0/24 to $new_ip port 80 proto tcp comment WHITELIST 
      /usr/sbin/ufw route allow in on enp5s0f0 out on enp5s0f1 from 192.168.0.0/24 to $new_ip port 443 proto tcp comment WHITELIST

      sleep 0.5
    fi
    # Here we will make a array entry indicating the last time the host was seen in the whitelist
    ip_whitelist_lastseen_times[$new_ip]=$(date '+%s')
  done
  echo "iterating to remove rules"
  #: for each existing IP in the ufw status list, if it is not in the new host list, it is due for removal
  current_time=$(date '+%s')
  for old_ip in "${old_ip_list[@]}"
  do
    if [ ! -v "new_ip_list_index[$old_ip]" ] ; then
      # marking eligible for deletion by calculating delta
      delta=$current_time
      if [ -v "ip_whitelist_lastseen_times[$old_ip]" ] ; then
        let "delta = $current_time - ${ip_whitelist_lastseen_times[$old_ip]}"
      fi
      if [ "$delta" -gt "300" ] ; then
        /usr/sbin/ufw route delete allow in on enp5s0f0 out on enp5s0f1 from 192.168.0.0/24 to $old_ip port 443 proto tcp 
        /usr/sbin/ufw route delete allow in on enp5s0f0 out on enp5s0f1 from 192.168.0.0/24 to $old_ip port 80 proto tcp

        sleep 0.5
        unset ip_whitelist_lastseen_times[$old_ip]
      fi
    fi
  done
  sleep 30
done

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .