Staging a Netfilter ruleset in a network namespace

Vincent Bernat

A com­mon way to build a fire­wall rule­set is to run a shell script call­ing iptables and ip6tables. This is con­ve­nient since you get ac­cess to vari­ables and loops. There are three ma­jor draw­backs with this method:

  1. While the script is run­ning, the fire­wall is tem­po­rar­ily in­com­plete. Even if ex­ist­ing con­nec­tions can be ar­ranged to be left un­touched, the new ones may not be al­lowed to be es­tab­lished (or unau­tho­rized flows may be al­lowed). Also, es­sen­tial NAT rules or man­gling rules may be absent.

  2. If an er­ror oc­curs, you are left with a half-work­ing fire­wall. There­fore, you should en­sure that some rules au­tho­riz­ing re­mote ac­cess are set very early. Or im­ple­ment some kind of au­to­matic roll­back system.

  3. Build­ing a large fire­wall can be slow. Each ip{,6}tables com­mand will down­load the rule­set from the ker­nel, add the rule and up­load the whole mod­i­fied rule­set to the kernel.

Up­date (2021-08)

While the so­lu­tion ex­posed in this ar­ti­cle is still cor­rect, a bet­ter al­ter­na­tive is to mi­grate to nfta­bles. It does not suf­fer from these problems.

Using iptables-restore#

A clas­sic way to solve these prob­lems is to build a rule file that will be read by iptables-restore and ip6tables-restore.1 These tools send the rule­set to the ker­nel in one pass. The ker­nel ap­plies it atom­i­cally. Usu­ally, such a file is built with ip{,6}tables-save but a script can fit the task.

Up­date (2016-04)

Also have a look at ferm, a tool to main­tain com­plex firewalls.

The rule­set syn­tax un­der­stood by ip{,6}tables-restore is sim­i­lar to the syn­tax of ip{,6}tables but each ta­ble has its own block and chain dec­la­ra­tion is dif­fer­ent. See the fol­low­ing example:

$ iptables -P FORWARD DROP
$ iptables -t nat -A POSTROUTING -s 192.168.0.0/24 -j MASQUERADE
$ iptables -N SSH
$ iptables -A SSH -p tcp --dport ssh -j ACCEPT
$ iptables -A INPUT -i lo -j ACCEPT
$ iptables -A OUTPUT -o lo -j ACCEPT
$ iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT
$ iptables -A FORWARD -j SSH
$ iptables-save
*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s 192.168.0.0/24 -j MASQUERADE
COMMIT

*filter
:INPUT ACCEPT [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:SSH - [0:0]
-A INPUT -i lo -j ACCEPT
-A FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -j SSH
-A OUTPUT -o lo -j ACCEPT
-A SSH -p tcp -m tcp --dport 22 -j ACCEPT
COMMIT

As you see, we have one block for the nat ta­ble and one block for the filter ta­ble. The user-de­fined chain SSH is de­clared at the top of the filter block with other builtin chains.

Here is a script di­vert­ing ip{,6}tables com­mands to build such a file—heav­ily re­ly­ing on some Zsh-fu:2

#!/bin/zsh
set -e

work=$(mktemp -d)
trap "rm -rf $work" EXIT

# ❶ Redefine ip{,6}tables
iptables() {
    # Intercept -t
    local table="filter"
    [[ -n ${@[(r)-t]} ]] && {
        # Which table?
        local index=${(k)@[(r)-t]}
        table=${@[(( index + 1 ))]}
        argv=( $argv[1,(( $index - 1 ))] $argv[(( $index + 2 )),$#] )
    }
    [[ -n ${@[(r)-N]} ]] && {
        # New user chain
        local index=${(k)@[(r)-N]}
        local chain=${@[(( index + 1 ))]}
        print ":${chain} -" >> ${work}/${0}-${table}-userchains
        return
    }
    [[ -n ${@[(r)-P]} ]] && {
        # Policy for a builtin chain
        local index=${(k)@[(r)-P]}
        local chain=${@[(( index + 1 ))]}
        local policy=${@[(( index + 2 ))]}
        print ":${chain} ${policy}" >> ${work}/${0}-${table}-policy
        return
    }
    # iptables-restore only handle double quotes
    echo ${${(q-)@}//\'/\"} >> ${work}/${0}-${table}-rules #'
}
functions[ip6tables]=${functions[iptables]}

# ❷ Build the final ruleset that can be parsed by ip{,6}tables-restore
save() {
    for table (${work}/${1}-*-rules(:t:s/-rules//)) {
        print "*${${table}#${1}-}"
        [ ! -f ${work}/${table}-policy ] || cat ${work}/${table}-policy
        [ ! -f ${work}/${table}-userchains || cat ${work}/${table}-userchains
        cat ${work}/${table}-rules
        print "COMMIT"
    }
}

# ❸ Execute rule files
for rule in $(run-parts --list --regex '^[.a-zA-Z0-9_-]+$' ${0%/*}/rules); do
    . $rule
done

# ❹ Execute rule files
ret=0
save iptables  | iptables-restore  || ret=$?
save ip6tables | ip6tables-restore || ret=$?
exit $ret

In ❶, a new iptables() func­tion is de­fined and will shadow the iptables com­mand. It will try to lo­cate the -t pa­ram­e­ter to know which ta­ble should be used. If such a pa­ram­e­ter ex­ists, the ta­ble is re­mem­bered in the $table vari­able and re­moved from the list of ar­gu­ments. Defin­ing a new chain (with -N) is also han­dled as well as set­ting the pol­icy (with -P).

In ❷, the save() func­tion will out­put a rule­set that should be parseable by ip{,6}tables-restore. In ❸, user rules are ex­e­cuted. Each ip{,6}tables com­mand will call the pre­vi­ously de­fined func­tion. When no er­ror has oc­curred, in ❹, ip{,6}tables-restore is in­voked. The com­mand will ei­ther suc­ceed or fail.

This method works just fine.3 How­ever, the sec­ond method is more elegant.

Using a network namespace#

A hy­brid ap­proach is to build the fire­wall rules with ip{,6}tables in a newly cre­ated net­work names­pace, save it with ip{,6}tables-save and ap­ply it in the main names­pace with ip{,6}tables-restore. Here is the gist (still us­ing Zsh syntax):

#!/bin/zsh
set -e

alias main='/bin/true ||'
[ -n $iptables ] || {
    # ❶ Execute ourself in a dedicated network namespace
    iptables=1 unshare --net -- \
        $0 4> >(iptables-restore) 6> >(ip6tables-restore)
    # ❷ In main namespace, disable iptables/ip6tables commands
    alias iptables=/bin/true
    alias ip6tables=/bin/true
    alias main='/bin/false ||'
}

# ❸ In both namespaces, execute rule files
for rule in $(run-parts --list --regex '^[.a-zA-Z0-9_-]+$' ${0%/*}/rules); do
    . $rule
done

# ❹ In test namespace, save the rules
[ -z $iptables ] || {
    iptables-save >&4
    ip6tables-save >&6
}

In ❶, the cur­rent script is ex­e­cuted in a new net­work names­pace. Such a names­pace has its own rule­set that can be mod­i­fied with­out al­ter­ing the one in the main names­pace. The $iptables en­vi­ron­ment vari­able tells in which names­pace we are. In the new names­pace, we ex­e­cute all the rule files (❸). They con­tain clas­sic ip{,6}tables com­mands. If an er­ror oc­curs, we stop here and noth­ing hap­pens, thanks to the use of set -e. Oth­er­wise, in ❹, the rule­set of the new names­pace is saved us­ing ip{,6}tables-save and sent to ded­i­cated file de­scriptors.

Now, the ex­e­cu­tion in the main names­pace re­sumes in ❶. The re­sults of ip{,6}tables-save are fed to ip{,6}tables-restore. At this point, the fire­wall is mostly op­er­a­tional. How­ever, we will play again the rule files (❸) but the ip{,6}tables com­mands will be dis­abled (❷). Ad­di­tional com­mands in the rule files, like en­abling IP for­ward­ing, will be executed.

The new names­pace does not pro­vide the same en­vi­ron­ment as the main names­pace. For ex­am­ple, there is no net­work in­ter­face in it, so we can­not get or set IP ad­dresses. A com­mand that must not be ex­e­cuted in the new names­pace should be pre­fixed by main:

main ip addr add 192.168.15.1/24 dev lan-guest

You can look at a com­plete ex­am­ple on GitHub.