feat: HostFirewallMixin, a Linux host's own netfilter rules (NetOrk/netork#1344)
CI / test (3.10) (push) Successful in 40s
CI / test (3.11) (push) Successful in 25s
CI / test (3.12) (push) Successful in 25s
CI / test (3.13) (push) Successful in 51s
CI / test (3.10) (pull_request) Successful in 24s
CI / test (3.11) (pull_request) Successful in 26s
CI / test (3.12) (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 25s

get_host_firewall() reads `nft -j list ruleset` as root and returns every
chain with its family, table, hook, priority and policy, and every rule as
matches and a verdict. Rules iptables-nft wrote show their extensions in nft's
JSON only as an opaque `xt`, so the tables iptables manages are taken from
iptables-save instead; a legacy iptables is kept beside nft's tables, since
both apply. A condition the parse does not model is an `unknown` match with
its text, never left out. Without root neither tool can be read, and
HostFirewallUnavailable is raised rather than a host without rules.

A driver supplies only _run_host_firewall_command(command, privileged=).
Version 4.21.0.
This commit is contained in:
christianmanivong committed 2026-10-21 23:10:44 +02:00
1 parent be3958e561
commit f77091c489
6 files changed
+1342 -1

No files matched your search

+501
View File
@@ -0,0 +1,501 @@
"""get_host_firewall: a Linux host's own netfilter rules, read once, in one shape.
Whether a service on a host is reachable from the internet is not only decided
by the address it listens on: the host's own firewall may drop the packets
before they reach it. Reading that is the same on every Linux host, so the
command and the parse live here, and a driver only carries the command across
(NetOrk/netork#1344).
``nft -j list ruleset`` is the primary reading. Rules that iptables-nft wrote
show their extensions there only as an opaque ``xt`` (``-m conntrack``,
``-m set``), so the tables iptables manages are taken from ``iptables-save``,
which spells them out. A legacy iptables is a ruleset of its own beside nft,
and both apply.
"""
from __future__ import annotations
import json
from shlex import quote
import pytest
from napalm_device_types.host_firewall import (
HOST_FIREWALL_COMMAND,
HostFirewallMixin,
HostFirewallUnavailable,
parse_host_firewall,
)
IPTABLES_NFT = """# Generated by iptables-save v1.8.10 (nf_tables) on Wed Oct 21 14:52:11 2026
*filter
:INPUT ACCEPT [2783578:295175610]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
:DOCKER-USER - [0:0]
:WGRUNNER - [0:0]
-A INPUT -s 10.255.255.4/32 -j WGRUNNER
-A INPUT -i ens6 -p udp -m udp --dport 51820 -j ACCEPT
-A INPUT -m set --match-set crowdsec-blacklists src -j DROP
-A INPUT -i ens6 -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -i ens6 -p tcp -m multiport --dports 80,443,8000:8010 -j ACCEPT
-A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW --ctorigdstport 2222 -j LOG --log-prefix "ssh "
-A INPUT -i ens6 -j DROP
-A FORWARD -j DOCKER-USER
-A DOCKER-USER -i eth+ ! -s 192.168.0.0/16 -g WGRUNNER
-A WGRUNNER -d 10.7.224.12/32 -p tcp -m tcp --dport 10051 -j ACCEPT
-A WGRUNNER -j REJECT --reject-with icmp-port-unreachable
-A WGRUNNER -j RETURN
COMMIT
# Completed on Wed Oct 21 14:52:11 2026
*nat
:PREROUTING ACCEPT [0:0]
:POSTROUTING ACCEPT [0:0]
:DOCKER - [0:0]
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A DOCKER ! -i br-1 -p udp -m udp --dport 53 -j DNAT --to-destination 172.18.0.2:53
COMMIT
"""
IP6TABLES_NFT = """# Generated by ip6tables-save v1.8.10 (nf_tables) on Wed Oct 21 14:52:11 2026
*filter
:INPUT DROP [0:0]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [0:0]
-A INPUT -i lo -j ACCEPT
COMMIT
"""
def _nft(*objects: dict) -> str:
meta = {"metainfo": {"version": "1.0.9", "json_schema_version": 1}}
return json.dumps({"nftables": [meta, *objects]})
#: The iptables-nft filter table as nft shows it -- with its extensions opaque --
#: and a native ``inet filter`` table of the host's own nftables.service.
NFT = _nft(
{"table": {"family": "ip", "name": "filter", "handle": 1}},
{
"chain": {
"family": "ip",
"table": "filter",
"name": "INPUT",
"type": "filter",
"hook": "input",
"prio": 0,
"policy": "accept",
}
},
{
"rule": {
"family": "ip",
"table": "filter",
"chain": "INPUT",
"expr": [
{"xt": {"type": "match", "name": "set"}},
{"counter": {"packets": 0, "bytes": 0}},
{"drop": None},
],
}
},
{"table": {"family": "inet", "name": "host", "handle": 9}},
{
"set": {
"family": "inet",
"table": "host",
"name": "admins",
"type": "ipv4_addr",
"flags": ["interval"],
"elem": [{"prefix": {"addr": "198.51.100.0", "len": 24}}],
}
},
{
"chain": {
"family": "inet",
"table": "host",
"name": "input",
"type": "filter",
"hook": "input",
"prio": 10,
"policy": "drop",
}
},
{"chain": {"family": "inet", "table": "host", "name": "services"}},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{
"match": {
"op": "in",
"left": {"ct": {"key": "state"}},
"right": ["established", "related"],
}
},
{"accept": None},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{"match": {"op": "==", "left": {"meta": {"key": "iif"}}, "right": "lo"}},
{"accept": None},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "ip", "field": "saddr"}},
"right": "@admins",
}
},
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "tcp", "field": "dport"}},
"right": {"set": [22, {"range": [8000, 8010]}]},
}
},
{"accept": None},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "wg*"}},
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "ip6", "field": "daddr"}},
"right": {"prefix": {"addr": "2001:db8::", "len": 32}},
}
},
{"jump": {"target": "services"}},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{"match": {"op": "==", "left": {"meta": {"key": "l4proto"}}, "right": "udp"}},
{"limit": {"rate": 10, "per": "second"}},
{"log": {"prefix": "udp "}},
{"reject": {"type": "icmpx", "expr": "port-unreachable"}},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "input",
"expr": [
{
"match": {
"op": "==",
"left": {"fib": {"result": "type", "flags": ["daddr"]}},
"right": "local",
}
},
{"goto": {"target": "services"}},
],
}
},
{
"rule": {
"family": "inet",
"table": "host",
"chain": "services",
"expr": [
{
"match": {
"op": "==",
"left": {"payload": {"protocol": "udp", "field": "dport"}},
"right": 53,
}
},
{"queue": {"num": 1}},
],
}
},
)
def _report(
*,
nft: str | None = NFT,
nft_rc: int = 0,
ipt: str | None = IPTABLES_NFT,
ip6t: str | None = IP6TABLES_NFT,
) -> str:
parts = ["$ sh -c ...", "FW_BEGIN"]
if nft is not None:
parts += ["[nft]", nft, f"__FW_RC={nft_rc}"]
if ipt is not None:
parts += ["[iptables-save]", ipt, "__FW_RC=0"]
if ip6t is not None:
parts += ["[ip6tables-save]", ip6t, "__FW_RC=0"]
parts.append("FW_END")
return "\n".join(parts) + "\n"
def _chain(reading, family, table, name):
(chain,) = [
c
for c in reading["chains"]
if (c["family"], c["table"], c["name"]) == (family, table, name)
]
return chain
def _fields(rule):
return [(m["field"], m["op"], m["values"]) for m in rule["matches"]]
class TestTheSources:
def test_iptables_tables_come_from_iptables_save_the_rest_from_nft(self):
reading = parse_host_firewall(_report())
assert reading["sources"] == ["nftables", "iptables-nft"]
tables = {(c["family"], c["table"]) for c in reading["chains"]}
assert tables == {("ip", "filter"), ("ip", "nat"), ("ip6", "filter"), ("inet", "host")}
# The filter table's INPUT is iptables-save's: its set match is spelled
# out there, and nft's opaque copy is not added a second time.
assert len(_chain(reading, "ip", "filter", "INPUT")["rules"]) == 7
def test_a_legacy_iptables_is_a_ruleset_of_its_own(self):
legacy = IPTABLES_NFT.replace("(nf_tables)", "(legacy)")
reading = parse_host_firewall(_report(ipt=legacy, ip6t=None))
assert reading["sources"] == ["nftables", "iptables-legacy"]
inputs = [c for c in reading["chains"] if c["name"] == "INPUT" and c["family"] == "ip"]
assert len(inputs) == 2 # nft's own ip filter INPUT and legacy's both apply
def test_nft_alone(self):
reading = parse_host_firewall(_report(ipt=None, ip6t=None))
assert reading["sources"] == ["nftables"]
assert _chain(reading, "ip", "filter", "INPUT")["rules"][0]["matches"] == [
{"field": "unknown", "op": "==", "values": ["xt match set"]}
]
def test_iptables_alone_when_nft_fails(self):
reading = parse_host_firewall(_report(nft="Operation not permitted", nft_rc=1))
assert reading["sources"] == ["iptables-nft"]
def test_no_tool_that_could_be_read(self):
with pytest.raises(HostFirewallUnavailable):
parse_host_firewall(_report(nft=None, ipt=None, ip6t=None))
def test_a_failed_reading_is_no_reading(self):
with pytest.raises(HostFirewallUnavailable):
parse_host_firewall(_report(nft="permission denied", nft_rc=1, ipt=None, ip6t=None))
def test_a_report_cut_short_raises(self):
with pytest.raises(ValueError):
parse_host_firewall(_report().replace("FW_END", ""))
def test_an_empty_ruleset_is_a_host_without_rules(self):
reading = parse_host_firewall(_report(nft=_nft(), ipt=None, ip6t=None))
assert reading == {"sources": ["nftables"], "chains": []}
class TestIptablesSave:
def setup_method(self):
self.reading = parse_host_firewall(_report(nft=None))
def test_a_built_in_chain_has_its_hook_and_policy(self):
chain = _chain(self.reading, "ip", "filter", "INPUT")
assert (chain["type"], chain["hook"], chain["priority"], chain["policy"]) == (
"filter",
"input",
0,
"accept",
)
assert _chain(self.reading, "ip6", "filter", "INPUT")["policy"] == "drop"
nat = _chain(self.reading, "ip", "nat", "PREROUTING")
assert (nat["type"], nat["hook"], nat["priority"]) == ("nat", "prerouting", -100)
def test_a_user_chain_has_none(self):
chain = _chain(self.reading, "ip", "filter", "WGRUNNER")
assert (chain["type"], chain["hook"], chain["priority"], chain["policy"]) == (
None,
None,
None,
None,
)
def test_matches_and_verdicts(self):
rules = _chain(self.reading, "ip", "filter", "INPUT")["rules"]
assert _fields(rules[0]) == [("saddr", "==", ["10.255.255.4/32"])]
assert (rules[0]["verdict"], rules[0]["target"]) == ("jump", "WGRUNNER")
assert _fields(rules[1]) == [
("iifname", "==", ["ens6"]),
("l4proto", "==", ["udp"]),
("dport", "==", ["51820"]),
]
assert rules[1]["verdict"] == "accept"
assert _fields(rules[2]) == [
("unknown", "==", ["-m set --match-set crowdsec-blacklists src"]),
]
assert _fields(rules[3]) == [
("iifname", "==", ["ens6"]),
("ct_state", "==", ["related", "established"]),
]
assert _fields(rules[4]) == [
("iifname", "==", ["ens6"]),
("l4proto", "==", ["tcp"]),
("dport", "==", ["80", "443", "8000-8010"]),
]
assert _fields(rules[5]) == [
("iifname", "!=", ["lo"]),
("l4proto", "==", ["tcp"]),
("ct_state", "==", ["new"]),
("ct_orig_dport", "==", ["2222"]),
]
assert rules[5]["verdict"] == "continue" # LOG decides nothing
assert rules[6]["verdict"] == "drop"
def test_negation_wildcard_and_goto(self):
(rule,) = _chain(self.reading, "ip", "filter", "DOCKER-USER")["rules"]
assert _fields(rule) == [("iifname", "==", ["eth*"]), ("saddr", "!=", ["192.168.0.0/16"])]
assert (rule["verdict"], rule["target"]) == ("goto", "WGRUNNER")
def test_reject_return_and_nat_targets(self):
rules = _chain(self.reading, "ip", "filter", "WGRUNNER")["rules"]
assert [r["verdict"] for r in rules] == ["accept", "reject", "return"]
(dnat,) = _chain(self.reading, "ip", "nat", "DOCKER")["rules"]
assert (dnat["verdict"], dnat["target"]) == ("other", "DNAT")
assert _chain(self.reading, "ip", "nat", "PREROUTING")["rules"][0]["matches"] == [
{"field": "unknown", "op": "==", "values": ["-m addrtype --dst-type LOCAL"]}
]
def test_the_line_is_kept_as_the_rules_text(self):
rule = _chain(self.reading, "ip", "filter", "INPUT")["rules"][6]
assert rule["text"] == "-A INPUT -i ens6 -j DROP"
class TestNftJson:
def setup_method(self):
self.chains = {
c["name"]: c
for c in parse_host_firewall(_report(ipt=None, ip6t=None))["chains"]
if c["table"] == "host"
}
def test_a_base_chain_and_a_regular_one(self):
base, regular = self.chains["input"], self.chains["services"]
assert (base["family"], base["type"], base["hook"], base["priority"], base["policy"]) == (
"inet",
"filter",
"input",
10,
"drop",
)
assert (regular["hook"], regular["policy"]) == (None, None)
def test_matches(self):
rules = self.chains["input"]["rules"]
assert _fields(rules[0]) == [("ct_state", "==", ["established", "related"])]
assert _fields(rules[1]) == [("iifname", "==", ["lo"])]
# A named set is resolved from its elements; tcp dport implies tcp.
assert _fields(rules[2]) == [
("saddr", "==", ["198.51.100.0/24"]),
("l4proto", "==", ["tcp"]),
("dport", "==", ["22", "8000-8010"]),
]
assert _fields(rules[3]) == [("iifname", "!=", ["wg*"]), ("daddr", "==", ["2001:db8::/32"])]
assert (rules[3]["verdict"], rules[3]["target"]) == ("jump", "services")
def test_a_statement_that_takes_only_some_packets_is_unknown(self):
rule = self.chains["input"]["rules"][4]
assert _fields(rule) == [("l4proto", "==", ["udp"]), ("unknown", "==", ["limit"])]
assert rule["verdict"] == "reject"
def test_an_expression_it_does_not_model_is_unknown(self):
rule = self.chains["input"]["rules"][5]
assert rule["matches"][0]["field"] == "unknown"
assert (rule["verdict"], rule["target"]) == ("goto", "services")
def test_a_verdict_it_does_not_model_is_other(self):
(rule,) = self.chains["services"]["rules"]
assert rule["verdict"] == "other"
def test_each_rule_reads_as_a_line(self):
assert self.chains["input"]["rules"][2]["text"] == (
"ip saddr 198.51.100.0/24 meta l4proto tcp tcp dport {22, 8000-8010} accept"
)
def test_an_unresolvable_set_is_unknown(self):
nft = NFT.replace('"@admins"', '"@missing"')
rules = [
c
for c in parse_host_firewall(_report(nft=nft, ipt=None, ip6t=None))["chains"]
if c["name"] == "input"
][0]["rules"]
assert rules[2]["matches"][0] == {
"field": "unknown",
"op": "==",
"values": ["ip saddr @missing"],
}
class _Host(HostFirewallMixin):
def __init__(self, output: str | None = None, fail: Exception | None = None):
self.calls: list[tuple[str, bool]] = []
self._output, self._fail = output, fail
def _run_host_firewall_command(self, command: str, *, privileged: bool) -> str:
self.calls.append((command, privileged))
if self._fail:
raise self._fail
return self._output or ""
class TestTheMixin:
def test_one_privileged_command(self):
host = _Host(_report())
reading = host.get_host_firewall()
assert host.calls == [(f"sh -c {quote(HOST_FIREWALL_COMMAND)}", True)]
assert reading["sources"] == ["nftables", "iptables-nft"]
def test_without_root_there_is_nothing_to_read(self):
# nft and iptables-save both need CAP_NET_ADMIN; a reading without root
# would be an empty ruleset that is not the host's.
host = _Host("FW_BEGIN\n[nft]\nnetlink: Operation not permitted\n__FW_RC=1\nFW_END\n")
with pytest.raises(HostFirewallUnavailable):
host.get_host_firewall()
assert len(host.calls) == 1
def test_the_command_is_framed_and_read_only(self):
assert "nft -j list ruleset" in HOST_FIREWALL_COMMAND
assert "iptables-save" in HOST_FIREWALL_COMMAND
for word in (" -F", " -X", "flush", "delete", "-A ", "-I ", "restore"):
assert word not in HOST_FIREWALL_COMMAND