feat(base): firewall catalog resolver filter plugin + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
390cd3b335
commit
4127f8bc6b
2 changed files with 145 additions and 0 deletions
72
roles/base/filter_plugins/firewall_rules.py
Normal file
72
roles/base/filter_plugins/firewall_rules.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""Resolve the shared firewall catalog into concrete nftables ingress rules for one host.
|
||||||
|
|
||||||
|
Used by the base role's nftables template (ADR-020 / host-nftables design). Pure
|
||||||
|
functions — unit-tested in tests/test_firewall_rules.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _placement_hosts(entry, groups):
|
||||||
|
"""Hostnames a catalog entry is placed on (exactly one of host/group/hosts)."""
|
||||||
|
if "host" in entry:
|
||||||
|
return [entry["host"]]
|
||||||
|
if "group" in entry:
|
||||||
|
return list(groups.get(entry["group"], []))
|
||||||
|
if "hosts" in entry:
|
||||||
|
return list(entry["hosts"])
|
||||||
|
raise ValueError(f"catalog entry has no placement (host/group/hosts): {entry!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _host_cidr(host, hostvars):
|
||||||
|
hv = hostvars.get(host) or {}
|
||||||
|
ip = hv.get("ansible_host")
|
||||||
|
if not ip:
|
||||||
|
raise ValueError(f"no ansible_host for '{host}' — cannot resolve firewall source")
|
||||||
|
return f"{ip}/32"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_source(frm, catalog, zones, hostvars, groups):
|
||||||
|
"""Resolve a symbolic `from` to a sorted list of source CIDRs."""
|
||||||
|
if frm in zones:
|
||||||
|
return [zones[frm]]
|
||||||
|
if frm in catalog:
|
||||||
|
return sorted(_host_cidr(h, hostvars)
|
||||||
|
for h in _placement_hosts(catalog[frm], groups))
|
||||||
|
if frm in groups:
|
||||||
|
return sorted(_host_cidr(h, hostvars) for h in groups[frm])
|
||||||
|
if frm in hostvars:
|
||||||
|
return [_host_cidr(frm, hostvars)]
|
||||||
|
raise ValueError(f"unresolvable firewall source '{frm}'")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_firewall_rules(catalog, zones, inventory_hostname, hostvars, groups):
|
||||||
|
"""Return sorted, de-duped [{proto, port, sources:[cidr,...]}] for services on this host."""
|
||||||
|
catalog = catalog or {}
|
||||||
|
zones = zones or {}
|
||||||
|
groups = groups or {}
|
||||||
|
|
||||||
|
rules = []
|
||||||
|
for _name, entry in sorted(catalog.items()):
|
||||||
|
if inventory_hostname not in _placement_hosts(entry, groups):
|
||||||
|
continue
|
||||||
|
for ing in entry.get("ingress", []):
|
||||||
|
rules.append({
|
||||||
|
"proto": ing.get("proto", "tcp"),
|
||||||
|
"port": int(ing["port"]),
|
||||||
|
"sources": _resolve_source(ing["from"], catalog, zones, hostvars, groups),
|
||||||
|
})
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for r in sorted(rules, key=lambda x: (x["port"], x["proto"], x["sources"])):
|
||||||
|
key = (r["proto"], r["port"], tuple(r["sources"]))
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
out.append(r)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class FilterModule:
|
||||||
|
"""Ansible filter plugin entry point."""
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
return {"resolve_firewall_rules": resolve_firewall_rules}
|
||||||
73
tests/test_firewall_rules.py
Normal file
73
tests/test_firewall_rules.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_PATH = (
|
||||||
|
pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
/ "roles" / "base" / "filter_plugins" / "firewall_rules.py"
|
||||||
|
)
|
||||||
|
_spec = importlib.util.spec_from_file_location("firewall_rules", _PATH)
|
||||||
|
fr = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(fr)
|
||||||
|
|
||||||
|
ZONES = {"lan": "10.30.0.0/24", "srv": "10.20.0.0/24"}
|
||||||
|
HOSTVARS = {
|
||||||
|
"docker01": {"ansible_host": "10.20.0.50"},
|
||||||
|
"docker02": {"ansible_host": "10.20.0.51"},
|
||||||
|
}
|
||||||
|
GROUPS = {"docker_hosts": ["docker01", "docker02"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_source():
|
||||||
|
cat = {"reverse_proxy": {"host": "docker01",
|
||||||
|
"ingress": [{"from": "lan", "port": 443, "proto": "tcp"}]}}
|
||||||
|
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||||
|
assert out == [{"proto": "tcp", "port": 443, "sources": ["10.30.0.0/24"]}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_source_resolves_to_host_ip():
|
||||||
|
cat = {
|
||||||
|
"reverse_proxy": {"host": "docker01", "ingress": []},
|
||||||
|
"photoprism": {"host": "docker01",
|
||||||
|
"ingress": [{"from": "reverse_proxy", "port": 2342, "proto": "tcp"}]},
|
||||||
|
}
|
||||||
|
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||||
|
assert out == [{"proto": "tcp", "port": 2342, "sources": ["10.20.0.50/32"]}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_placement_and_source_multi_host():
|
||||||
|
cat = {"dns": {"group": "docker_hosts",
|
||||||
|
"ingress": [{"from": "docker_hosts", "port": 53, "proto": "udp"}]}}
|
||||||
|
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||||
|
assert out == [{"proto": "udp", "port": 53,
|
||||||
|
"sources": ["10.20.0.50/32", "10.20.0.51/32"]}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_with_no_services_returns_empty():
|
||||||
|
cat = {"photoprism": {"host": "docker02",
|
||||||
|
"ingress": [{"from": "lan", "port": 2342, "proto": "tcp"}]}}
|
||||||
|
assert fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unresolvable_from_raises():
|
||||||
|
cat = {"x": {"host": "docker01",
|
||||||
|
"ingress": [{"from": "nope", "port": 80, "proto": "tcp"}]}}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_rules_deduped():
|
||||||
|
cat = {"app": {"host": "docker01", "ingress": [
|
||||||
|
{"from": "lan", "port": 8080, "proto": "tcp"},
|
||||||
|
{"from": "lan", "port": 8080, "proto": "tcp"},
|
||||||
|
]}}
|
||||||
|
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||||
|
assert out == [{"proto": "tcp", "port": 8080, "sources": ["10.30.0.0/24"]}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_ansible_host_raises():
|
||||||
|
cat = {"x": {"host": "docker01",
|
||||||
|
"ingress": [{"from": "docker02", "port": 80, "proto": "tcp"}]}}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
fr.resolve_firewall_rules(cat, ZONES, "docker01", {"docker01": {}, "docker02": {}}, GROUPS)
|
||||||
Loading…
Add table
Reference in a new issue