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
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:
1 parent
be3958e561
commit
f77091c489
6 files changed
+1342
-1
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user