ipsets-dynamic script with systemd
This page shows how to use a BASH script executed on an interval by a SystemD Timer for the purpose of checking entries in specified ipset lists that have a comment of the format "resolve:fqdn - note". The SystemD service runs after the netfilter-persistent.service starts and then periodically based on the timer intervals.
Assumptions
ipset list -output json is a new feature. Only works on Ubuntu 24.04 and not previous versions. A workaround will be to install yq and use it to convert xml to json. Quote cleanup in the comment field will be needed.
apt install ipset-persistent iptables-persistent netfilter-persistent
# file:/etc/iptables/ipsets
create dynamicallowlist hash:net family inet hashsize 1024 maxelem 65536 comment
add dynamicallowlist 1.2.3.4 comment "resolve:some-relevant-fqdn.bogus.com - Office - Denver, CO - ATT Fiber"
# file:/etc/iptables/rules.v4
# super simple example
*filter
:INPUT ACCEPT [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:WAN-IN ACCEPT [0:0]
:allowed-management - [0:0]
# allowed-management chain
-A allowed-management -s 6.7.8.9 -j ACCEPT -m comment --comment "Static endpoint just in case ipsets don't load properly"
-A allowed-management -j RETURN
-A INPUT -i lo -j ACCEPT -m comment --comment "Accept all localhost traffic"
-A INPUT -i eth0 -j WAN-IN -m comment --comment "Push inbound WAN traffic to WAN-IN chain"
-A WAN-IN -m set --match-set dynamicallowlist src -j ACCEPT -m comment --comment "Allow all traffic from managed dynamicallowlist"
-A WAN-IN -j allowed-management -m comment --comment "Legacy allowed-management chain... just in case ipsets load fails"
-A WAN-IN -p tcp -m tcp --dport 22 -j DROP -m comment --comment "Drop all other inbound SSH requests"
# list other services you want to explicitely protect
-A WAN-IN -m state --state ESTABLISHED -j ACCEPT
-A WAN-IN -m state --state RELATED -j ACCEPT
-A WAN-IN -m state --state NEW -j DROP -m comment --comment "Drop all other NEW connections"
-A WAN-IN -j DROP -m comment --comment "Drop everything else that might find its way here"
Components
mkdir -p /opt/ipsets-dynamic/
touch /opt/ipsets-dynamic/ipsets-dynamic /opt/ipsets-dynamic/ipsets-dynamic.service /opt/ipsets-dynamic/ipsets-dynamic.timer
chmod 755 /opt/ipsets-dynamic/ipsets-dynamic
apt install jq
# file:/etc/systemd/system/ipsets-dynamic.service
[Unit]
Description=Update ipset entries flagged by resolve comments
After=netfilter-persistent.service
Wants=netfilter-persistent.service
[Service]
Type=oneshot
ExecStart=/opt/ipsets-dynamic/ipsets-dynamic
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# file:/etc/systemd/system/ipsets-dynamic.timer
[Unit]
Description=Run ipsets-dynamic every 5 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
#!/usr/bin/env bash
# Created: 20260205
SYSLOG_TAG="ipsets-dynamic"
IPSETS=("dynamicallowlist")
#DEBUG=1
debug() {
[[ -n "$DEBUG" ]] && echo "$@"
}
for IPSET_NAME in "${IPSETS[@]}"; do
debug "==============================="
debug "Processing ipset: $IPSET_NAME"
ipset list $IPSET_NAME -output json | grep -v initval | jq -c '
.[0].members[]
| select(.comment | contains("resolve:"))
| {
elem,
comment,
fqdn: (.comment | capture("resolve:(?<fqdn>[^ ]+)").fqdn)
}
' |
while read -r entry; do
listed_ip=$(echo "$entry" | jq -r '.elem')
comment=$(echo "$entry" | jq -r '.comment')
fqdn=$(echo "$entry" | jq -r '.fqdn')
debug "--------------------------------"
debug "IPSET : $IPSET_NAME"
debug "FQDN : $fqdn"
debug "Comment : $comment"
debug "Listed IP : $listed_ip"
DIG_RECORD_LINE=$(dig +noall +answer "$fqdn" | grep "IN\s*A")
if [ -n "$DIG_RECORD_LINE" ]; then
TTL=$(echo "$DIG_RECORD_LINE" | awk '{print $2}')
ADDRESS=$(echo "$DIG_RECORD_LINE" | awk '{print $5}')
debug "Resolved IP : $ADDRESS ($TTL seconds TTL)"
if [[ "$listed_ip" != "$ADDRESS" ]]; then
debug ""
echo "$SYSLOG_TAG - ipset update needed - $IPSET_NAME / $listed_ip -> $ADDRESS / $comment"
logger -i "$SYSLOG_TAG - ipset update needed - $IPSET_NAME / $listed_ip -> $ADDRESS / $comment"
ipset del $IPSET_NAME $listed_ip
if [[ $? -ne 0 ]]; then
debug " Failure deleting ipset entry $IPSET_NAME / $listed_ip for $fqdn"
logger -i -p user.error "$SYSLOG_TAG - ipset update failure deleting entry $IPSET_NAME / $listed_ip / $comment"
else
debug " Deleted $IPSET_NAME / $listed_ip"
fi
ipset add $IPSET_NAME $ADDRESS comment "$comment"
if [[ $? -ne 0 ]]; then
debug " [ERROR] ipset update failure adding entry $IPSET_NAME / $ADDRESS for $fqdn"
logger -i -p user.error "$SYSLOG_TAG - ipset update failure adding entry $IPSET_NAME / $ADDRESS / $comment"
else
debug " Added $IPSET_NAME / $ADDRESS for $fqdn"
fi
fi
else
debug "No A record found for $fqdn"
TTL="N/A"
ADDRESS="N/A"
fi
debug ""
done
debug ""
done
# make sure we signal a successful exit for systemd
exit 0
systemctl daemon-reload
systemctl enable --now ipsets-dynamic.timer
systemctl list-timers ipsets-dynamic.timer
watch systemctl list-timers ipsets-dynamic.timer
journalctl -u ipsets-dynamic.service -n 50