moat docs ยท language 1
GitHub

Importing Importing iptables rules

Importing iptables rules turn the rules you already have into a moat file.

If the host already filters with iptables, you don't have to start from an empty file. moat import iptables reads what iptables-save prints and writes the equivalent moat rules, with every iptables line kept as a comment above the rule it became.

$ iptables-save | moat import iptables -o rules.moat
$ { iptables-save; ip6tables-save; } | moat import iptables -o rules.moat

The second form imports IPv4 and IPv6 together. moat import iptables [FILE] [-o OUT] [--family 4|6] reads FILE, or stdin when FILE is left out, and writes to OUT, or stdout. Input it cannot read exits with 2.

It reads the chains a packet arriving for this host goes through, in the kernel's order: PREROUTING of the raw table, PREROUTING of the mangle table, then INPUT of the filter table. Their rules are named raw_N, mangle_N and input_N. Other chains (FORWARD, OUTPUT, the nat table) are not read; at the end the importer lists those that have rules, with how many, as in nat PREROUTING: 2 rules.

An example

Given this INPUT chain:

*filter
:INPUT DROP [0:0]
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A INPUT -m set --match-set blocklist src -j DROP
-A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
-A INPUT -s 203.0.113.0/24 -p tcp --dport 22 -j ACCEPT
-A INPUT -p udp --dport 27015 -m hashlimit --hashlimit-above 50/sec --hashlimit-burst 100 --hashlimit-mode srcip --hashlimit-name q -j DROP
COMMIT

moat writes (header comments shortened):

version 1

# The INPUT policy is DROP, but moat has no connection state: ...
default pass

# ipset blocklist, assumed to hold addresses or networks ...
#   moat set sync ipset_blocklist FILE --iface IF
set ipset_blocklist prefixes

# not translated (connection state is not visible to XDP): -A INPUT -m conntrack ...
# an accept above could not be translated: check this rule does not drop what it let in
# iptables: -A INPUT -m set --match-set blocklist src -j DROP
rule input_2 { when ip4 and ip.src in ipset_blocklist then drop }
# iptables: -A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT
rule input_3 { when ip4 and tcp and tcp.dport in {80, 443} then pass }
# iptables: -A INPUT -s 203.0.113.0/24 -p tcp --dport 22 -j ACCEPT
rule input_4 {
    when ip4 and ip.src in {203.0.113.0/24} and tcp and tcp.dport == 22
    then pass
}
# an accept above could not be translated: check this rule does not drop what it let in
# iptables: -A INPUT -p udp --dport 27015 -m hashlimit --hashlimit-above 50/sec ...
rule input_5 {
    when ip4 and udp and udp.dport == 27015
    then limit 50/s per ip.src burst 100
}

On stderr it prints a summary and the warnings described below.

Translated

iptablesmoat
-s, -d, -m iprange --src-range/--dst-rangeip.src == ADDR for one host, ip.src in {...} for prefixes and ranges
-p tcp/udp/icmp/icmpv6 with a port or header testtcp, udp, icmp, icmp6
-p udp alone, ! -p tcpip.proto == 17, ip.proto != 6, which match fragments too, as iptables does
--dport, --sport, -m multiporttcp.dport == 22 for one port, tcp.dport in {...} for several
--syn, --tcp-flags MASK COMPconditions on tcp.flags
--icmp-type, --icmpv6-typeicmp.type, icmp.code
-m set --match-set NAME src/dstip.src in ipset_NAME and an empty set ipset_NAME prefixes
-m ttl, -m hlip.ttl
-m length --lengthip.len
-m hashlimit --hashlimit-above R with -j DROPlimit R per ip.src[/N] burst B
-m string --string, --hex-string (with --algo, --to)payload contains, with a note and a warning
-m comment --commenta comment above the rule
-j ACCEPT, -j DROP, -j REJECTpass, drop, drop (XDP cannot answer)

-m string needs -p tcp or -p udp and a text of 1 to 32 bytes, and a file can search for at most 16 different texts. iptables searched the whole packet, up to --to; moat searches the first 256 bytes of the payload, so the translated rule carries a note and a warning saying so.

ACCEPT in raw and mangle

In iptables, ACCEPT in raw or mangle only ends that table: the packet still goes through the tables after it. So when a later table has rules, such an ACCEPT does not become pass. Instead the rules below it in the same table get a not (...) guard, so they leave those packets alone, and the packet goes on to the next table's rules.

*raw
-A PREROUTING -s 192.0.2.0/24 -j ACCEPT
-A PREROUTING -p udp --dport 53 -m string --string "ANY" --algo bm --to 200 -j DROP
*filter
-A INPUT -p tcp --dport 22 -j ACCEPT

becomes

# iptables searched the packet up to byte 200, moat searches the first 256 bytes of the payload
rule raw_2 {
    when ip4 and udp and udp.dport == 53 and payload contains "ANY" and not (ip4 and ip.src in {192.0.2.0/24})
    then drop
}
rule input_1 { when ip4 and tcp and tcp.dport == 22 then pass }

Not translated

These stay in the file as # not translated (REASON): LINE:

  • connection state (-m state, -m conntrack): XDP runs before connection tracking;
  • -m connlimit: it counts open connections, which XDP does not see;
  • -m recent: it keeps its own list of sources. An addresses set with add and a ttl does the same, written by hand;
  • -m string with --icase (moat compares bytes exactly), with --from other than 0, or negated;
  • -m limit and hashlimit --hashlimit-upto: they accept below the rate, while a moat limit acts above it. Rewrite them as hashlimit --hashlimit-above with DROP, or by hand;
  • ! --icmp-type any, which never matches;
  • targets that do not end the chain (LOG, SET, MARK, CT, NOTRACK and the like). They do not count as accepts, so a final DROP after them is kept;
  • jumps to other chains (-j MYCHAIN, -g), RETURN and other targets. A jump to a chain counts as an accept that could not be translated, so the drops after it get the warning below;
  • -i and -o: moat applies to the interface it is loaded on, and loopback traffic never passes through it;
  • negations moat cannot express exactly, and any match not listed above.

Warnings to read

The importer prints how many rules it translated, each line it left as a comment with the reason, the chains it did not read, and these warnings:

  • An accept that could not be translated. Typically the ESTABLISHED rule at the top. Every drop rule after it is marked with a comment, because in iptables that accept would have let some of those packets through first.
  • A catch-all drop after such an accept. A rule like -A INPUT -j DROP would drop the established connections and the SSH sessions the untranslated accepts allowed, so it is left out with a warning.
  • A drop that would cut everything such an accept let through. Take -p udp --dport 30120 -m state --state ESTABLISHED -j ACCEPT followed by -p udp --dport 30120 -j DROP. Without its state test the accept matches UDP to port 30120, and the drop tests nothing more than that, so enforcing it would cut the established traffic too. A drop whose tests are all among an untranslated accept's own, once the state test is removed, is left out with a warning.
  • INPUT policy DROP. The file still says default pass. moat has no connection state, so a drop default would also drop the replies to connections the host opens itself. Keep iptables behind moat for stateful filtering, or switch to default drop only once every flow the host needs is allowed above it.

ipsets

iptables-save does not include ipset contents. Save them too and give both to the importer:

$ ipset save > sets.save
$ iptables-save > rules.v4
$ moat import iptables rules.v4 --ipsets sets.save -o rules.moat
moat import: wrote ipset_blocklist.txt

Every ipset the rules use whose type holds addresses or networks (hash:ip, hash:net, bitmap:ip) is written as a set file next to the output, ipset_NAME.txt, one entry per line; a range becomes the networks that cover it exactly, and a set with netmask N gives /N networks. The set is declared from that file, so it is filled at every load:

# ipset blocklist (hash:net), 2 entries from ipset save
set ipset_blocklist from "ipset_blocklist.txt"

The set is left empty, with a warning, when its type holds more than addresses (hash:ip,port, for example), when it has nomatch entries, or when the rules use it and the save output does not have it. Entries with a timeout are copied but become permanent, also with a warning. --ipsets needs -o, since it writes files next to the rules, and does not apply to nftables.

Without --ipsets, each set becomes an empty set ipset_NAME prefixes to fill once the rules are loaded. Either way, moat set sync refreshes a set later from a newer list:

$ ipset list blocklist | sed '1,/^Members:/d' | cut -d' ' -f1 > blocklist.txt
$ moat set sync ipset_blocklist blocklist.txt --iface eth0

Migrating safely

  1. Import, then read every not translated comment and warning.
  2. moat check rules.moat, and write a few cases for the traffic that must get through (SSH from your address, your service ports).
  3. sudo moat load rules.moat --iface eth0 --audit: nothing is dropped yet; moat stats --iface eth0 shows what each rule would drop.
  4. When the "would drop" counts look right, sudo moat audit off --iface eth0.

moat sees every packet arriving on the interface, before routing and NAT, including traffic forwarded to virtual machines and containers, which an INPUT chain never sees. Keep iptables running behind moat: moat drops the floods cheaply, iptables keeps the stateful rules.