Friday, May 27, 2011

Dynamic Firewall Rules for IPTables


#!/bin/bash
# note: Bash is needed since we use arrays and pattern matching
#
# Dynamic address rules for iptables
# ----------------------------------
#
# $Log$
# Revision 1.4  2010/03/19 19:03:12 muzso
# The requirements section did not list "dig".
#
# $Log$
# Revision 1.3  2010/02/11 09:03:12 muzso
# The network interface name was turned into a parameter.
#
# $Log$
# Revision 1.2  2009/10/15 21:24:27 muzso
# Bugfix release.
# If there was already a rule in the rule_generation_specs array and a new rule
# was added and the cache file already existed with the old rule's cache record,
# then the iptables chain's rules were regenerated, but only the new spec rule
# was applied ... thus IPs of all old rules were denied access.
#

# $Log$
# Revision 1.1  2009/03/04 13:26:34 muzso
# Minor bugfixes and logging modifications.
#
# $Log$
# Revision 1.0  2009/02/22 16:37:13 muzso
# Initial release.
#
# Description
# -----------
#
# Purpose of this is script is to generate iptables rules based on hostnames
# and update those rules, when the IP of the hostname changes.
# The primary application is to allow incoming TCP connections from clients
# with dynamic IP addresses based on dynamic DNS names.
#
# Requirements
# ------------
#
# The script uses standard linux/unix commands like bash, touch, sed, awk, grep,
# dig, umask, date, echo, set and of course iptables. Some of these are probably
# implemented internally in your version of bash.
# I've tested only on Debian 4.x (Etch) and 5.x (Lenny), but probably should
# work on all Linux systems and with a few tweaks it could work on Unix boxes
# too.
#
# How to use
# ----------
#
# 1. Create a new chain in your firewall script.
#    Eg. iptables -N dynamic_address_rules
#        iptables -A dynamic_address_rules -j RETURN
# 2. Feed packets into the new chain in your firewall script.
#    You should do this somewhere near the end of TCP related rules in your
#    firewall script.
#    Eg. iptables -A INPUT -p TCP -i eth0 -j dynamic_address_rules
# 3. Call this script at the end of your firewall script and in a cron job.
#    Eg. at the end the firewall script:
#      test -x /adminscripts/dynamic_address_rules.sh && /adminscripts/dynamic_address_rules.sh
#    And in /etc/crontab (to run this script every 5 minutes):
#      */5 * * * * test -x /adminscripts/dynamic_address_rules.sh && /adminscripts/dynamic_address_rules.sh
# 4. And of course, set the various configuration variables in the config
#    section of this script.
#
# Configuration
# -------------
#
# The list of specifications for iptables rule generation is defined in the
# rule_generation_specs variable (array).
#
#   - The list of records is whitespace separated.
#   - Each record must be surrounded by quotes.
#   - Basically one line should contain one record (for better readability), but
#     you can put as many records in a single line as you wish.
#   - A record contains a number of fields separated by semicolons.
#   - The first field is mandatory and must contain a single fqdn (the trusted
#     host).
#   - The second field is mandatory and must contain a list of destination
#     specifications separated by commas.
#     A destination specification contains an optional address and a mandatory
#     port (separated by a colon which is optional if host is not present).
#   - You can have an optional third field containing a list of DNS servers
#     (separated by commas) that will be used to get the IP of the host.
#     If there's no DNS server specified, then the default DNS resolution will
#     be used (which is usually based on /etc/resolv.conf).
#     If a list is specified, then the elements are queried in the specified
#     order for the IP of the host until one returns a valid answer.
#
# Example:
#   example.dyndns.org;22,192.168.0.113:8080;ns1.dyndns.org,ns2.dyndns.org
#
#   This will create ACCEPT rules with:
#     - the source IP of example.dyndns.org
#     - destinations of:
#       - port 22 (all IPs)
#       - address 192.168.0.113 and port 8080
#
#   It will first query ns1.dyndns.org for the IP address of example.dyndns.org,
#   and if that fails it will query ns2.dyndns.org.
#
# Logging
# -------
#
# You can enable logging by setting the "logfile" variable (to a file).
#
# Debugging
# ---------
#
# Extra debug output is generated if the DEBUG variable is set in the
# environment of the script. If logging is disabled, the debug output goes
# to stdout.
#
# Eg. start the script with:
#   env DEBUG=true ./dynamic_address_rules


#################
# Configuration #
#################

rule_generation_specs=(
  "dynamic1.example.com;22,8080;ns1.example.com,ns2.example.com"
  "dynamic2.address.example.com;22"
)

# the iptables binary
IPT=/sbin/iptables

# the name of the network interface to use in the iptables rules
IFACE=eth0

# name of the chain that should contain the rules for dynamic addresses
chain=dynamic_address_rules

# file to store cached addresses in
cache=/root/dynamic_address_rules.cache

# log file (uncomment if you want logs to be generated)
# (If enabled, you should also set up log rotation!)
#logfile=/var/log/dynamic_address_rules.log

# uncomment the following line to enable debugging persistently
# (ie. for each invocation of the script)
#DEBUG=yes

#################
# End of config #
#################

function log() {
  if [ -n "${logfile}" -a -n "${1}" ]; then
    echo "${*}" >> "${logfile}"
  fi
}

function debug_log() {
  if [ -n "${DEBUG}" -a -n "${1}" ]; then
    if [ -n "${logfile}" ]; then
      echo "${*}" >> "${logfile}"
    else
      echo "${*}"
    fi
  fi
}

curdate=$(date '+%Y%m%d%H%M%S')

umask 0077

if [ -n "${logfile}" ]; then
  touch "${logfile}" > /dev/null 2>&1
  if [ ! -f "${logfile}" -o ! -w "${logfile}" ]; then
    echo "${curdate}: failed to create (or open for write) the logfile at \"${logfile}\"."
    exit 1
  fi
fi

debug_log "${curdate}: starting"

touch "${cache}" > /dev/null 2>&1
if [ ! -f "${cache}" -o ! -w "${cache}" ]; then
  log "${curdate}: failed to create (or open for write) the cache file at \"${cache}\"."
  exit 1
fi

chain_rules=$(${IPT} -n -L ${chain} 2> /dev/null)
chain_exists=${?}
debug_log "${curdate}: current rules in chain \"${chain}\" ..."
debug_log "${chain_rules}"
debug_log "${curdate}: chain rules end"
if [ -n "${chain_rules}" ]; then
  chain_rules=$(echo "${chain_rules}" | grep '^[A-Z]\{3,\}')
  if [ -n "${chain_rules}" ]; then
    chain_real_rule_count=$(echo "${chain_rules}" | grep -cv '^RETURN')
    return_rule_count=$(echo "${chain_rules}" | grep -c '^RETURN')
  else
    chain_real_rule_count=0
    return_rule_count=0
  fi
else
  chain_real_rule_count=0
  return_rule_count=0
fi
debug_log "${curdate}: chain real rule count - ${chain_real_rule_count}"
debug_log "${curdate}: return rule count - ${return_rule_count}"

if [ ${chain_exists} -eq 0 ]; then
  debug_log "${curdate}: the specified iptables chain \"${chain}\" exists."
  if [ ${#rule_generation_specs[*]} -gt 0 ]; then
    
    if [ -f "${cache}" ]; then
      cache_mem=$(< "${cache}")
    fi
    
    rules_changed=0
    commands=()
    
    for record in "${rule_generation_specs[@]}"; do
      debug_log "${curdate}: processing record: ${record}"
      set -- $(echo "${record}" | tr ";" " ")
      src_host=${1}
      dst_specs=($(echo "${2}" | tr "," " "))
      src_dnsservers=($(echo "${3}" | tr "," " "))
      debug_log "  Host: ${src_host}"
      debug_log "  Destination specs: ${dst_specs[@]}"
      debug_log "  Destination specs count: ${#dst_specs[*]}"
      debug_log "  DNS servers: ${src_dnsservers[@]}"
      debug_log "  DNS servers count: ${#src_dnsservers[*]}"
      if [ ${#dst_specs[*]} -gt 0 ]; then
        src_address=""
        if [ ${#src_dnsservers[*]} -eq 0 ]; then
          debug_log "  Querying address for host ${src_host}."
          dns_response=$(dig +noall +answer ${src_host} 2>&1)
          debug_log "  DNS response:"
          debug_log "${dns_response}"
          src_address=$(echo "${dns_response}" | awk '/[ \n\t][0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?$/{address=$NF}END{print address}')
          if [ -n "${src_address}" ]; then
            debug_log "  Got address: ${src_address}"
          else
            log "${curdate}: failed to get address for host ${src_host}."
          fi
        else
          for src_dns in "${src_dnsservers[@]}"; do
            debug_log "  Querying address for host ${src_host} from DNS server ${src_dns}."
            dns_response=$(dig +noall +answer @${src_dns} ${src_host} 2>&1)
            debug_log "  DNS response:"
            debug_log "${dns_response}"
            src_address=$(echo "${dns_response}" | awk '/[ \n\t][0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?\.[0-9][0-9]?[0-9]?$/{address=$NF}END{print address}')
            if [ -n "${src_address}" ]; then
              debug_log "  Got address: ${src_address}"
              break;
            else
              log "${curdate}: failed to get address for host ${src_host} from DNS server ${src_dns}."
            fi
          done
        fi
        if [ -n "${src_address}" ]; then
          src_host_regex=$(echo "${src_host}" | sed "s/\./\\./g")
          cached_address=$(echo "${cache_mem}" | grep "^${src_host_regex}:" | cut -d: -f2)
          debug_log "  Cached address for ${src_host}: ${cached_address}"

          new_record=$(echo "${src_host}:${src_address}:${curdate}")
          if [ -n "${cache_mem}" ]; then
            cache_mem=$(echo "${cache_mem}" | grep -v "^${src_host_regex}:")
            cache_mem="${cache_mem}
${new_record}"
          else
            cache_mem="${new_record}"
          fi
          
          for dst_spec in "${dst_specs[@]}"; do
            debug_log "  Destination spec: ${dst_spec}"
            set -- $(echo "${dst_spec}" | tr ":" " ")
            if [ -n "${1}" ]; then
              if [ -n "${2}" ]; then
                cmd="${IPT} -A ${chain} -i ${IFACE} -p TCP -s ${src_address} -d ${1} --dport ${2} -j ACCEPT"
              else
                cmd="${IPT} -A ${chain} -i ${IFACE} -p TCP -s ${src_address} --dport ${1} -j ACCEPT"
              fi
              commands[${#commands[*]}]=$(echo "${cmd}" | tr " " "#")
              debug_log "  iptables command: ${cmd}"
            fi
          done
          
          # The rules have changed if any of the following conditions are met for any of the rules:
          # - the host is not yet in the cache
          # - address in the cache is different from the current address
          # - there're no rules yet in the chain (other than RETURN)
          if [ -z "${cached_address}" -o "${src_address}" != "${cached_address}" -o ${chain_real_rule_count} -eq 0 ]; then
            if [ -z "${cached_address}" ]; then
              log "${curdate}: no cached address was found for host ${src_host}. Updating rules with new address (${src_address})."
            else
              if [ "${src_address}" != "${cached_address}" ]; then
                log "${curdate}: cached address (${cached_address}) for host ${src_host} does not match current address (${src_address}). Updating rules."
              else
                # chain_real_rule_count == 0
                log "${curdate}: there're no iptables rules for chain ${chain}. Forcing update for host ${src_host} with address (${src_address})."
              fi
            fi
            rules_changed=1
          else
            debug_log "  Cached address (${cached_address}) matches current address for host ${src_host}."
          fi
        fi
      fi
    done

    debug_log ""
    if [ ${rules_changed} -eq 1 -a ${#commands[*]} -gt 0 ]; then
      debug_log "${curdate}: rules have changed"
      echo "${cache_mem}" > "${cache}"

      # flush (empty) the chain
      debug_log "${curdate}: flushing chain"
      ${IPT} -F ${chain}
      return_rule_count=0

      debug_log "${curdate}: running commands"
      for command in "${commands[@]}"; do
        if [ -n "${command}" ]; then
          cmd=($(echo "${command}" | tr "#" " "))
          debug_log "  ${cmd[@]}"
          ${cmd[@]}
        fi
      done
    else
      debug_log "${curdate}: no rules have changed."
    fi
  else
    debug_log "${curdate}: no rule generation specs were defined."
  fi

  if [ ${return_rule_count} -eq 0 ]; then
    # add a RETURN rule to the end of the chain
    debug_log "${curdate}: no RETURN rules were found in chain \"${chain}\", adding one."
    ${IPT} -A ${chain} -j RETURN
  fi
else
  log "${curdate}: the specified iptables chain \"${chain}\" does not exist."
fi
debug_log "${curdate}: finished"

1 Comments:

Anonymous said...

Thanks, this is great! I will be modifying your implementation though. I want it to apply changes upon a Radius "accounting start" transaction that will update the rules corresponding to the user profile.