Shami's Blog

Sysadmin, Because Even Developers Need Heroes

Using Dynamic DNS with pf, iptables, and gomplate

2021-06-06

It is always better to tighten the firewall configuration on your servers. Limiting SSH connections from a defined list of IP addresses greatly reduces the attack surface as well as load; the firewall is much more efficient at blocking connections than sshd. The best approach is to have a static IP or connect through a bastion host which is protected via a VPN. But sometimes that is not feasible.

What I am describing here is not scalable, but it is what I started to do at the beginning and worked well enough for me, and most importantly, it’s totally free.

The idea came to me while setting up my last instance of pfSense . pfSense allows you to create firewall rules using DNS names, which in turn can be dynamic.

First you need a dynamic DNS record set up. ClouDNS has a free service. I haven’t tried it but I have used ClouDNS’s services and have no reservation recommending them. They have been stellar for the couple of years I have used them. You can also use another service if you prefer. I will not discuss how to do it here but I might write about how I do it in a future post.

Assuming the following hostnames:

  • user1.example.com
  • user2.example.com
  • user3.example.com

pf

pf has the ability to store IP lists in separate files using tables. A sample would be

scrub in all

ext_if="igb0"

# IP1: HQ
# IP2: Branch office 1
# IP3: Trusted static IP
SafeHosts="{IP1, IP2, IP3}"
table <user1> persist file "/etc/pf.user1.example.com"
table <user2> persist file "/etc/pf.user2.example.com"
table <user3> persist file "/etc/pf.user3.example.com"

# Do not intercept local traffic
set skip on { lo0 }

# Allow ping
pass in quick inet proto icmp all

# Pass from safe hosts (Anti-lockout rule)
pass in quick proto tcp from $SafeHosts to port ssh flags any
pass in quick from $SafeHosts
pass in quick proto tcp from <user1> to port ssh flags any
pass in quick from <user1>
pass in quick proto tcp from <user2> to port ssh flags any
pass in quick from <user2>
pass in quick proto tcp from <user3> to port ssh flags any
pass in quick from <user3>

# Pass outbound traffic
pass out quick all

# Block everything by default
block in all

# Allow traffic
pass in proto tcp to $ext_if port http
pass in proto tcp to $ext_if port https

And now for the script

#!/bin/sh

# Tolerate no errors
set -e

RELOAD=0

for host in user1.example.com user2.example.com user3.example.com
do
    # Resolve the hostname and compare it to the one we currently have configured
    curIP=`getent hosts $host| cut -d' ' -f1 | sort`
    configuredIP=`cat /etc/pf.$host`

    # If the user's IP changed, save the IP and set pf to be reloaded
    if [ "$curIP" != "$configuredIP" ]; then
        echo "$curIP" > /etc/pf.$host
        RELOAD=1
    fi
done

if [ $RELOAD -eq 1 ]; then
    service pf reload
fi

Simply add this script to CRON and let it run every 5 minutes or at whatever duration you are comfortable with. The script will only reload the firewall if the IPs change so no load on the firewall. Also note that you can save all IPs in a single table but having a table for each user allows finer grain controls.

iptables

iptables has IP sets which should handle this, but I honestly don’t use iptables as often so didn’t have the chance to look into it. For those who understand IP sets, they can modify the script above to modify the required IP set instead of the table files.

For those who don’t, like myself. There is gomplate

Form the gomplate’s documentation

gomplate is a template renderer which supports a growing list of datasources, such as: JSON (including EJSON - encrypted JSON), YAML, AWS EC2 metadata, BoltDB, Hashicorp Consul and Hashicorp Vault secrets.

A sample iptables template would be

# Generated by iptables-save, then edited manually
*mangle
:PREROUTING ACCEPT [3809:279796]
:INPUT ACCEPT [3809:279796]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [3366:4523482]
:POSTROUTING ACCEPT [3366:4523482]
COMMIT
# Completed on Thu Feb 11 05:39:13 2020
# Generated by iptables-save v1.8.4 on Thu Feb 11 05:39:13 2020
*nat
:PREROUTING ACCEPT [418:25288]
:INPUT ACCEPT [220:13024]
:OUTPUT ACCEPT [20:1482]
:POSTROUTING ACCEPT [20:1482]
# Completed on Thu Feb 11 05:39:13 2020
# Generated by iptables-save v1.8.4 on Thu Feb 11 05:39:13 2020
*filter
:INPUT DROP [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [74:8418]
# Allow all communication on localhost
-A INPUT -i lo -j ACCEPT

# ICMP
-A INPUT -p icmp -j ACCEPT

# Allow incoming packets related to established connections
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT

# SSH
-A INPUT -s {{ (ds "ips").user1_example_com }}/32 -p tcp -m tcp --dport 22 -j ACCEPT
-A INPUT -s {{ (ds "ips").user2_example_com }}/32 -p tcp -m tcp --dport 22 -j ACCEPT
-A INPUT -s {{ (ds "ips").user3_example_com }}/32 -p tcp -m tcp --dport 22 -j ACCEPT

# Web
-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT

COMMIT
# Completed on Thu Feb 11 05:39:13 2020

And the script would be

#!/bin/sh

# Tolerate no errors
set -e

# Clear the temp file
cat /dev/null > /tmp/ips

for host in user1.example.com user2.example.com user3.example.com
do
    # Resolve the hostname and compare it to the one we currently have configured
    # Only fetch one record
    curIP=`getent hosts $host| cut -d' ' -f1 | sort | head -1`

    # Store IPs in a YAML file
    eval "echo `echo $host | tr '.' '_'`: ${curIP}" >> /tmp/ips
done

# Generate the rules file
gomplate_linux-amd64 -d ips=/tmp/ips -f iptables.tpl > /tmp/rules.v4

# If the files are different, that means at least one IP address changed
diff /etc/iptables/rules.v4 /tmp/rules.v4 || cat /tmp/rules.v4 > /etc/iptables/rules.v4 && /usr/bin/systemctl restart netfilter-persistent.service

Then add this script to CRON and you should be set.

Yes this is a bit tedious and I have already switched to using another solution which I will share in a future post. But I thought this would be useful to someone so I thought I would share.

About Me

Dev gone Ops gone DevOps. Any views expressed on this blog are mine alone and do not necessarily reflect the views of my employer.

twitter linkedin