---
title: "An iptables ruleset, line by line"
date: "2019-12-10"
path: "/199-an-iptables-ruleset-line-by-line.html"
section: "/ramblings"
description: "An annotated iptables-restore file for a single web server: default drop, open web ports, admin ports limited to trusted addresses, and how to read the output of iptables-save."
---

I've been asked more than once to look at a server's firewall and say whether it's any good. The first thing I do is `iptables-save` and read it top to bottom. This is the ruleset I start new single-purpose web servers from, with every line explained, followed by notes on reading a dump that someone else (or firewalld) wrote.

## The file

Saved as `/etc/iptables/rules.v4` and loaded with `iptables-restore`:

```text
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]
:ADMIN - [0:0]

-A INPUT -i lo -j ACCEPT
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -m conntrack --ctstate INVALID -j DROP
-A INPUT -p icmp --icmp-type echo-request -m limit --limit 5/second --limit-burst 10 -j ACCEPT
-A INPUT -p icmp --icmp-type destination-unreachable -j ACCEPT
-A INPUT -p icmp --icmp-type time-exceeded -j ACCEPT

-A INPUT -p tcp -m multiport --dports 80,443 -m conntrack --ctstate NEW -j ACCEPT

-A INPUT -p tcp -m multiport --dports 22,3306,10000 -m conntrack --ctstate NEW -j ADMIN

-A ADMIN -s 203.0.113.10/32 -j ACCEPT
-A ADMIN -s 198.51.100.0/28 -j ACCEPT
-A ADMIN -j DROP

-A INPUT -m limit --limit 5/min -j LOG --log-prefix "iptables drop: " --log-level 4
COMMIT
```

## Line by line

`*filter` starts the filter table, the one that decides what is allowed. Everything up to `COMMIT` is applied as one change, so a half-loaded ruleset never exists.

`:INPUT DROP [0:0]` sets the policy for the INPUT chain: whatever reaches the end of the chain without matching a rule is dropped. This is the most important line in the file. A lot of rulesets I've been handed have `:INPUT ACCEPT` and a list of rules that send a few ports to a restricted chain. That locks those ports down, but every port not on the list, including whatever service someone installs next month, is open. Default drop turns it the right way round: only what you name is allowed. The `[0:0]` is the packet and byte counters, which `iptables-save` writes and `iptables-restore` reads back.

`:FORWARD DROP` because a web server isn't a router. If you run Docker or a VPN on it, this changes.

`:OUTPUT ACCEPT` lets the server talk out freely. Restricting outbound is worth doing on a high-value box, as it stops a compromised site phoning home, but it's a lot more work to get right and I don't start there.

`:ADMIN - [0:0]` creates a user-defined chain. User chains have no policy, hence the dash; when a packet reaches the end of one it returns to the rule after the jump.

`-A INPUT -i lo -j ACCEPT` accepts everything on the loopback interface. PHP talking to MySQL on 127.0.0.1, Postfix talking to itself and plenty else depend on this. It belongs at the top of INPUT, not tucked into a restricted chain where it only applies to the listed ports.

`--ctstate RELATED,ESTABLISHED -j ACCEPT` lets in replies to connections the server made (DNS lookups, package downloads, outgoing mail) and the rest of any connection already accepted. Without it, default drop would break everything the server does outbound. It goes near the top because it matches most packets, so most packets stop being checked here.

`--ctstate INVALID -j DROP` drops packets connection tracking can't place: out-of-state TCP, odd flag combinations, a lot of scan traffic.

The three ICMP lines allow ping at a limited rate, plus the two error types that matter. `destination-unreachable` carries the "fragmentation needed" message that path MTU discovery relies on, and blocking it gives you connections that hang on large transfers for no obvious reason. Blocking all ICMP is a common mistake.

`--dports 80,443 ... -j ACCEPT` is the public service. `multiport` lets one rule cover several ports. Matching on `NEW` is strictly redundant after the established rule, but it makes the intent clear.

`--dports 22,3306,10000 ... -j ADMIN` sends SSH, MySQL and the Webmin port to the `ADMIN` chain instead of accepting them.

In `ADMIN`, each `-s` line accepts one trusted address or range: an office line, a VPN endpoint, a bastion host. The final `-A ADMIN -j DROP` drops anyone else. Strictly the INPUT policy would drop them anyway, but an explicit drop at the end of a restricted chain means it still works if someone later changes the policy.

The `LOG` line logs what's about to be dropped by the policy, rate-limited so a scan doesn't fill the disk. The messages land in the kernel log (`journalctl -k` or `/var/log/kern.log`). Turn it off once you're happy.

## Loading it safely

Never load a new ruleset on a remote server without a way back. `iptables-apply` loads a file and rolls it back if you don't confirm within a timeout:

```bash
iptables-apply -t 60 /etc/iptables/rules.v4
```

If you lose the session, wait a minute and it's undone. Once it's right, make it load at boot. On Debian and Ubuntu:

```bash
apt-get install iptables-persistent
netfilter-persistent save
```

That writes the running rules to `/etc/iptables/rules.v4` and `rules.v6` and loads them at boot. Older servers did the same job with an `iptables-restore < /etc/iptables.up.rules` line in `/etc/network/interfaces`.

IPv6 is a separate ruleset. If the server has an IPv6 address and you only write the v4 rules, everything is open over v6. The same structure works for `ip6tables`, with IPv6 addresses in the admin chain and `ipv6-icmp` in place of the ICMP lines; also allow `neighbour-solicitation` and `neighbour-advertisement`, or the server can't find its own gateway.

## Reading a dump someone else wrote

`iptables-save` output from a live server has a few extra things in it.

Counters on the chain lines (`:INPUT ACCEPT [52310:48871204]`) show how many packets and bytes have reached that point since the counters were last reset. A user chain sitting on `[0:0]` on a busy server has never matched anything, which is worth asking about.

Several tables appear: `nat`, `mangle`, `raw`, `security`. On a plain web server they're normally empty apart from their chain definitions. Anything in `nat` on a box that isn't a router or a Docker host deserves a look.

firewalld writes its own structure: chains such as `INPUT_ZONES`, `IN_public`, `IN_public_allow`, with the actual open ports sitting in the `_allow` chains. Its rules use `-g` (goto) as well as `-j` (jump). A goto doesn't come back to the calling chain when it reaches the end; the packet falls to the policy or the rule after the original jump further up. Don't hand-edit these; change them with `firewall-cmd` and read them here.

fail2ban adds rules that match an `ipset` and reject, something like:

```text
-A INPUT -p tcp -m multiport --dports 22 -m set --match-set f2b-sshd src -j REJECT --reject-with icmp-port-unreachable
```

These need to sit above your accept rules to have any effect; fail2ban inserts them at the top for that reason. If you reload your own ruleset from a file, fail2ban's rules go with it until fail2ban is restarted.

A final `-j REJECT --reject-with icmp-host-prohibited` at the end of INPUT, as on Red Hat systems, works much like a drop policy, but the sender is told straight away that it was refused rather than left to time out. Both are fine; drop gives a scanner less to go on.

## nftables

On Debian 10 the `iptables` command is a front end to nftables (`iptables-nft`), and everything above still works with it. If you're starting from scratch, `iptables-restore-translate -f rules.v4` will turn a file like this into native `nft` syntax.
