Simplifying iptables firewalls

I’m ill (f***ing flu) so what would be better than playing around with iptables? Shortly after starting I saw myself writing a wrapper for iptables in bash to simplify my life.

Something which really drives me crazy when working with iptables is that you have to repeat so much code. Let’s use a short example for that

IPT=/sbin/iptables
 
TRUSTED_NETS="127.0.0.1/8 10.0.0.0/8"
# trusted ports are CLOSED for trusted networks
TRUSTED_PORTS="3306"
# public ports are OPEN for everyone
PUBLIC_PORTS="20 21 22 80 443"
 
$IPT -N trusted
$IPT -N public
 
for PORT in ${TRUSTED_PORTS[@]}; do
  $IPT -A trusted -p tcp --dport ${PORT} -j REJECT --with-reject tcp-reset
  $IPT -A trusted -p udp --dport ${PORT} -j REJECT --with-reject icmp-port-unreachable
done
$IPT -A trusted -p all -j ACCEPT
 
for PORT in ${PUBLIC_PORTS[@]}; do
  $IPT -A public -p tcp --dport ${PORT} -j ACCEPT
  $IPT -A public -p udp --dport ${PORT} -j ACCEPT
done
# bad idea, just for example here
$IPT -A public -p all -j DROP
 
$IPT -A INPUT -i lo -j ACCEPT
$IPT -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
for NET in ${TRUSTED_NETS[@]}; do
  $IPT -A INPUT -p all -s ${NET} -j trusted
done
$IPT -A INPUT -p all -j public

So, what does happen here is simple: First of all, all packets incoming on the loopback device are allowed. Then all packets which are of state established or related are accepted (and will hence skip further rules). Then I do route all networks which are trusted through the trusted chain (these should be packets of state NEW as I’ve allowed ESTABLISHED and RELATED already – might be UNTRACKED or INVALID though). Instead of going the usual route I am using something I’d call a hybrid firewall. Usually firewalls are split into two categories:

  • restrictive ones (i.e. block everything not explicitly allowed)
  • conservative ones (i.e. allow everything and block specific stuff)

I am sure this specific sorts have their own name which I just do not remember while writing this. Anyway, here I am going to use a conservative one for my internal hosts and a restrictive one for packets arriving from the public, i.e. internet. The trusted chain makes sure everything is allowed BUT the mysql port. The public chain makes sure everything is disallowed except for ftp, ssh, http and https. Furthermore for my internal hosts I am polite by sending a REJECT instead of just droping the connection as I do for public systems accessing the system.

As you can see in the above example I am using „-A“ and „-j“ often. For a firewall script you most likely never use -I (well. at least not, if the firewall has been written properly I’d say) so adding a rule to a chain will always require you to type -A. Same for „-j“ the last „word“ in most of my iptables rules is always the action and hence -j is always required (ignoring parameters of the action for now). Something else you can see is, that I do have to use two rules when accepting packets since iptables does not have the possibility to block or accept TCP and UDP with just one rule. A workaround which is not really one I’ve seen on the web would be to create a custom chain called for example BLOCK_TCP_UDP like so:

$IPT -N BLOCK_TCP_UDP
$IPT -A BLOCK_TCP_UDP -p tcp -j REJECT
$IPT -A BLOCK_TCP_UDP -p udp -j REJECT
$IPT -A BLOCK_TCP_UDP -j RETURN

With a probability of 99,99% this won’t do what you want. Why? First of all, iptables works sequentially which means that you want to get a match as early as possible. So you won’t have packets which need to traverse all rules (at least, the good ones shouldn’t) otherwise you’ll slow down packet processing. The above chain makes sense to be used like so:

$IPT -A INPUT -p ALL -j BLOCK_TCP_UDP

But in this case you’ll also send ICMP through that chain except you’ve accepted those packets beforehand or marked or … How about the port? You cannot use –dport or –sport together with -p all which means that traffic from all ports would go through that chain. So, you would still have to write two lines of code:

$IPT -A INPUT -p tcp --dport .. -j BLOCK_TCP_UDP
$IPT -A INPUT -p udp --dport .. -j BLOCK_TCP_UDP

What did you gain from the custom chain? Nothing but increased complexity. Something which annoys me as well is that if you hack a lot of iptables rules into a simple bash script you do not know which rule failed. Assume the following in a bash script:

$IPT -A INPUT -p icmp --dport 444 -j DROP
$IPT -A INPUT -p tcp --dport 444 -j DROP
$IPT -A INPUT -p udp --dport 444 -j DROP

One of these rules is wrong – which one? Right, -p icmp does not have –dport. Now assume you’ll have ~100 of these rules. How long will it take you to spot the error? Your bash script just says:

christine jean # ./test.sh 
iptables v1.6.0: unknown option "--dport"
Try `iptables -h' or 'iptables --help' for more information.

So you do not know which rule exactly caused the error. You just know that some rule does not have the option –dport. Obviously that’s not much fun if you need to debug such a script. So, what I came up with are some really simple wrappers which might even fail in some circumstances – so be sure you know what you’re doing if you use them.

Let us solve the above problems one by one. First of all the debugging one. Instead of writing

$IPT -A INPUT -p tcp --dport 80 -j ACCEPT

I’ll write

add_rule -A INPUT -p tcp --dport 80 -j ACCEPT

for which I’d use the following wrapper:

IPT=/sbin/iptables
_ERR_HDR_FMT="%.23s %s[%s]: "
_ERR_MSG_FMT="${_ERR_HDR_FMT}%s\n"
 
error_msg() {
  printf "$_ERR_MSG_FMT" $(date +%F.%T.%N) ${BASH_SOURCE[1]##*/} ${BASH_LINENO[1]} "${@}"
}
 
add_rule() {
  local rule="$@"
 
  $IPT $rule 2>/dev/null
 
  if [ "$?" != 0 ]; then
    error_msg "$rule";
    return 1
  fi
 
  return 0
}

What does happen now if I run my test.sh?

christine jean # ./test.sh 
2016-10-31.21:19:28.327 test.sh[24]: -A INPUT -p icmp --dport 444 -j DROP

I can see the rule which failed and I can see that the rule is on line 24. Snippet taken from here: http://stackoverflow.com/a/3056595 quick look at the documentation here https://www.gnu.org/software/bash/manual/html_node/Bash-Variables.html which tells me that

An array variable whose members are the line numbers in source files where each corresponding member of FUNCNAME was invoked. ${BASH_LINENO[$i]} is the line number in the source file (${BASH_SOURCE[$i+1]}) where ${FUNCNAME[$i]} was called (or ${BASH_LINENO[$i-1]} if referenced within another shell function). Use LINENO to obtain the current line number.

Or.. in simple terms: I need 1 and not 0 as key for BASH_LINENO. 🙂 Now I could go even further by doing something like this:

if ! add_rule "-A foobar -p tcp -j RETURN"; then
  $IPT -N foobar
  add_rule "-A foobar -p tcp -j RETURN"
fi

It does print out an error (as to be expected) though it did create the foobar chain and added the rule to it. I could use this for compatibility as well, if for example I would like to use another match if a specific match is not supported. For example something like this:

if ! add_rule "-A foobar -m state --state ESTABLISHED,RELATED -j ACCEPT"; then
  # connection tracking not supported.. do something else instead
 
fi

Might be useful in some container-variants like LXC or OpenVZ if iptables is not fully supported. Alright. Goal 1 reached. Let’s get back to issue 2: That’s way too much to type. We can get rid of -A just by adding -A in front of $rule like so:

add_rule() {
  local rule="$@"
 
  $IPT -A $rule 2>/dev/null
 
  if [ "$?" != 0 ]; then
    error_msg "$rule";
    return 1
  fi
 
  return 0
}

To create chains we’d still use $IPT -N new_chain. I’m fine with that. Now that I won’t have to use -A again, I want to get rid of -j. That is more tricky. First of all let us split an iptables rule into three parts:

  • chain
  • parameters
  • -j action

The specific add_rule command could look like „add_rule chain parameters action“ or „add rule INPUT -p tcp –dport 80 reject“. For that I could just use awk to get the first word:

chain=$(echo "${rule}" | awk '{print $1}')

then get the last word:

action=$(echo "${rule}" | awk 'NF>1{print $NF}')

and finally get everything between

param=$(echo "${rule}" | awk '{$1=$NF=""}1' | sed 's/^ *//;s/ *$//')

Assuming that your method add_rule filled the variable $rule with $@. But the action part is tricky, since you might want to have parameters there like „reject –reject-with tcp-reset“ now the last word is „tcp-reset“. Another way to achieve what we want would be to use -j as delimiter, for example like so:

param=$(echo "${rule}" | awk '{$1=""}1' | sed 's/-j.*//g')
actionpart=$(echo "${rule}" | sed 's/.*-j//g');
action=$(echo "${actionpart}" | awk '{print $1}');
actionparam=$(echo "${actionpart}" | sed 's/'$action'//g');

The first word after -j is the action. Everything after that word are parameters to the action. This will still require me to type -j which I do not want. So I do need something like that: last word of a string which is not surrounded by „“ and which does not start with – nor –. There might be better solutions but I think easiest is to just remove words surrounded by „“ and words starting with – or — from the string and then taking the last remaining word, like so:

action=$(echo "${rule}" | sed 's_\".*\"__g' | sed 's_--[^ ]*__g' | awk 'NF>1{print $NF}')

So the whole stuff looks like this:

chain=$(echo "${rule}" | awk '{print $1}')
action=$(echo "${rule}" | sed 's_\".*\"__g' | sed 's_--[^ ]*__g' | awk 'NF>1{print $NF}')
param=$(echo "${rule}" | awk '{$1=""}1' | sed 's/'${action}'.*//g')
actionparam=$(echo "${rule}" | sed 's_.*'${action}'__g')
 
# example rule
jean@christine ~ $ echo $rule
INPUT -p tcp --dport 80 LOG --log-prefix "foobar"
 
# result
jean@christine ~ $ echo $chain
INPUT
jean@christine ~ $ echo $action
LOG
jean@christine ~ $ echo $param
-p tcp --dport 80
jean@christine ~ $ echo $actionparam
--log-prefix "foobar"

This allows me to map every action to it’s own method. So I could create a method „reject_tcp_and_udp“ to reject packets for tcp and udp. For that I would make that method return an array called RULES which will usually just contain only one element. For our reject_tcp_and_udp() method it would however contain two elements like so:

reject_tcp_and_udp() {
  RULES[0]="-p tcp $1 -j REJECT $2"
  RULES[1]="-p udp $1 -j REJECT $2"
}

The call would look like this:

add_rule INPUT -s 1.2.3.4 -m state --state NEW reject_tcp_and_udp

Now in my add_rule wrapper I just need to iterate over $RULES and make sure that $RULES gets unset. Remember, we’re in bash, so you NEED to unset $RULES, setting $RULES to empty won’t kill its child elements, wait what?

jean@christine ~ $ rule[0]="hello"
jean@christine ~ $ rule[1]="world"
jean@christine ~ $ echo $rule[0]
hello[0]
jean@christine ~ $ echo ${rule[0]}
hello
jean@christine ~ $ echo ${rule[1]}
world
jean@christine ~ $ echo ${rule[@]}
hello world
jean@christine ~ $ rule=""
jean@christine ~ $ echo ${rule[0]}
 
jean@christine ~ $ echo ${rule[1]}
world
jean@christine ~ $ echo ${rule[@]}
world
jean@christine ~ $ unset rule
# empty
jean@christine ~ $ echo ${rule[@]}
# empty
jean@christine ~ $ echo ${rule[1]}

See? Alright, back to the wrapper… Already feeling like that the wrapper will take more lines than the firewall itself.. but well..

#!/bin/bash
 
IPT=/sbin/iptables
_ERR_HDR_FMT="%.23s %s[%s]: "
_ERR_MSG_FMT="${_ERR_HDR_FMT}%s\n"
 
error_msg() {
  printf "$_ERR_MSG_FMT" $(date +%F.%T.%N) ${BASH_SOURCE[1]##*/} ${BASH_LINENO[1]} "${@}"
}
 
reject_tcp_and_udp() {
  RULES[0]="-p tcp $1 -j REJECT $2"
  RULES[1]="-p udp $1 -j REJECT $2"
}
 
accept_tcp_and_udp() {
  RULES[0]="-p tcp $1 -j REJECT $2"
  RULES[1]="-p udp $1 -j REJECT $2"
}
 
add_rule() {
  local rule="$@"
  local chain=$(echo "${rule}" | awk '{print $1}')
  local action=$(echo "${rule}" | sed 's%\".*\"%%g' | sed 's%--[^ ]*%%g' | awk 'NF>1{print $NF}')
  local param=$(echo "${rule}" | awk '{$1=""}1' | sed 's%'${action}'.*%%g')
  local actionparam=$(echo "${rule}" | sed 's%.*'${action}'%%g')
 
  if [ "$(type -t $action)" != function ]; then
    RULES[0]="${param} -j ${action} ${actionparam}"
  else
    # instead of defining RULES get the rule(s) form the specific method
    $action "${param}" "${actionparam}"
  fi
 
  for RULE in "${RULES[@]}"; do
    # surpress errors, as we do have our own error reporting :p
    $IPT -A ${chain} ${RULE} 2>/dev/null
    if [ "$?" != 0 ]; then
      echo "error processing ${RULE}...";
      return 1
    fi
  done
 
  unset RULES 
  return 0
}
 
add_rule INPUT -m state --state NEW reject_tcp_and_udp
add_rule INPUT -p tcp --dport 444 DROP
add_rule INPUT -p udp --dport 444 DROP

and now the original script becomes…

IPT=/sbin/iptables
 
TRUSTED_NETS="127.0.0.1/8 10.0.0.0/8"
# trusted ports are CLOSED for trusted networks
TRUSTED_PORTS="3306"
# public ports are OPEN for everyone
PUBLIC_PORTS="20 21 22 80 443"
 
$IPT -N trusted
$IPT -N public
 
for PORT in ${TRUSTED_PORTS[@]}; do
  add_rule trusted --dport ${PORT} reject_tcp_and_udp
done
add_rule trusted ACCEPT
 
for PORT in ${PUBLIC_PORTS[@]}; do
  add_rule public --dport ${PORT} accept_tcp_and_udp
done
# bad idea, just for example here
add_rule public DROP
 
add_rule INPUT -i lo ACCEPT
add_rule INPUT -m state --state ESTABLISHED,RELATED ACCEPT
for NET in ${TRUSTED_NETS[@]}; do
  add_rule INPUT -s ${NET} trusted
done
add_rule INPUT public

/me shrugs

No Comments

Post a Comment