feat(firewall): add generic diff/apply mechanism for firewall rules

FirewallRuleDict/FirewallRuleDiffDict (models.py) plus three abstract
methods (get_firewall_rules/apply_firewall_rule/commit_firewall_rules)
concrete drivers implement, and two concrete methods every driver gets
for free: diff_firewall_rules() matches desired vs. live rules by
description and reports add/update (never delete -- a firewall may carry
manually-created rules a caller's desired set was never meant to
describe); apply_firewall_ruleset() orchestrates applying the diff and
yields progress lines, meant for streaming to a caller.

This is the generic reconciliation engine NetOrk's Firewall Profile
feature needs against OPNsense -- kept here instead of in
napalm-opnsense since the matching/comparison/orchestration logic is
identical for any firewall vendor that implements the three abstract
methods.
This commit is contained in:
2026-07-20 15:01:13 +02:00
parent b3d67d1517
commit 90b8e08789
3 changed files with 355 additions and 1 deletions
+146 -1
View File
@@ -10,10 +10,13 @@ Usage::
...
"""
from typing import Any, Dict, List
from typing import Any, Dict, Iterator, List, Optional
from napalm_device_types.base import DeviceTypeDriver
from napalm_device_types._ucd_metrics import IF_SKIP_DEFAULT, collect_ucd_metrics
from napalm_device_types.models import (
FirewallRuleDict,
FirewallRuleDiffDict,
FirewallRuleUpdateDict,
HealthMetricsDict,
NATTranslationDict,
PackageDict,
@@ -22,6 +25,20 @@ from napalm_device_types.models import (
VPNTunnelDict,
)
_FIREWALL_RULE_COMPARE_FIELDS = (
"action",
"interface",
"direction",
"protocol",
"source_net",
"source_port",
"destination_net",
"destination_port",
"log",
"quick",
"enabled",
)
class FirewallDriver(DeviceTypeDriver):
TYPE_LABEL: str = "Firewall"
@@ -325,3 +342,131 @@ class FirewallDriver(DeviceTypeDriver):
)
"""
raise NotImplementedError
# ------------------------------------------------------------------
# Firewall rule diff/apply. get_firewall_rules/apply_firewall_rule/
# commit_firewall_rules are abstract (device communication); everything
# else here is a concrete, vendor-neutral algorithm -- see README.md
# "Design principle: generic vs. device-specific logic".
# ------------------------------------------------------------------
def get_firewall_rules(self) -> List[FirewallRuleDict]:
"""
Returns all firewall filter rules currently configured on the device.
`description` must be a stable, human-assigned identifier -- it is
the key used to match rules across calls (most firewall vendors
don't expose an ID a caller can pre-assign).
:raises NotImplementedError: If the driver does not support reading
firewall rules.
"""
raise NotImplementedError
def apply_firewall_rule(
self, rule: FirewallRuleDict, *, uuid: Optional[str] = None
) -> Dict[str, Any]:
"""
Creates or updates a single firewall filter rule on the device.
:param rule: The desired rule state, in vendor-neutral form.
:param uuid: If given, update the existing rule with this ID
in-place. If ``None``, create a new rule.
:raises NotImplementedError: If the driver does not support writing
firewall rules.
:raises ValueError: If `rule` references an alias/interface the
device doesn't know about.
:raises RuntimeError: If the device rejects the write.
:returns: A dict with at least ``{"success": bool}``.
"""
raise NotImplementedError
def commit_firewall_rules(self) -> Dict[str, Any]:
"""
Applies pending firewall filter rule changes (e.g. reloads pf/pfctl,
or whatever the device's equivalent of "Apply Changes" is).
Call once after one or more `apply_firewall_rule()` calls -- not
after every single rule.
:raises NotImplementedError: If the driver does not support this
(e.g. rules take effect immediately on write).
:returns: A dict with at least ``{"success": bool}``.
"""
raise NotImplementedError
def diff_firewall_rules(self, desired: List[FirewallRuleDict]) -> FirewallRuleDiffDict:
"""
Compares `desired` against the device's current rules and returns
what would need to change to reach that state.
Matches rules by `description`. A desired rule with no live
counterpart becomes an "add"; a live rule whose description matches
but whose other fields differ becomes an "update". Live rules with
no matching desired entry are **not** reported for deletion -- this
is intentionally conservative: a firewall may carry manually-created
or otherwise unmanaged rules that a caller's `desired` set was never
meant to describe, and this method has no way to distinguish those
from ones simply no longer wanted. Callers wanting delete/cleanup
semantics must implement that themselves, deliberately.
:param desired: The complete desired rule set.
:returns: ``{"add": [...], "update": [{"uuid", "rule",
"changed_fields"}, ...]}``.
"""
live_by_description: Dict[str, FirewallRuleDict] = {
rule["description"]: rule for rule in self.get_firewall_rules()
}
add: List[FirewallRuleDict] = []
update: List[FirewallRuleUpdateDict] = []
for desired_rule in desired:
live_rule = live_by_description.get(desired_rule["description"])
if live_rule is None:
add.append(desired_rule)
continue
changed_fields = [
field
for field in _FIREWALL_RULE_COMPARE_FIELDS
if live_rule.get(field) != desired_rule.get(field)
]
if changed_fields:
update.append(
{
"uuid": live_rule["uuid"],
"rule": desired_rule,
"changed_fields": changed_fields,
}
)
return {"add": add, "update": update}
def apply_firewall_ruleset(self, desired: List[FirewallRuleDict]) -> Iterator[str]:
"""
Computes the diff against `desired` and applies it, yielding one
human-readable progress line per change, then commits.
Intended for streaming to a caller (e.g. an SSE endpoint) that wants
live progress while writing to a real device.
:param desired: The complete desired rule set.
:yields: Progress lines, one per applied add/update, plus a final
commit line.
"""
diff = self.diff_firewall_rules(desired)
for rule in diff["add"]:
self.apply_firewall_rule(rule)
yield f"[add] {rule['description']}"
for entry in diff["update"]:
self.apply_firewall_rule(entry["rule"], uuid=entry["uuid"])
fields = ", ".join(entry["changed_fields"])
yield f"[update] {entry['rule']['description']} ({fields})"
self.commit_firewall_rules()
yield f"[commit] applied {len(diff['add'])} add(s), {len(diff['update'])} update(s)"
+39
View File
@@ -314,6 +314,45 @@ class SessionDict(TypedDict):
age: float
class FirewallRuleDict(TypedDict):
"""A single firewall filter rule, in vendor-neutral form.
`description` is the stable matching key across get_firewall_rules()/
diff_firewall_rules()/apply_firewall_rule() -- firewall vendors
generally don't expose an ID a caller can pre-assign, so the rule's
human description is what ties a "desired" rule to its "live"
counterpart. `source_net`/`source_port`/`destination_net`/
`destination_port` are plain strings (comma-joined by the caller if a
rule references multiple aliases) -- driver methods never expand or
split them.
"""
uuid: str
description: str
action: str
interface: str
direction: str
protocol: str
source_net: str
source_port: str
destination_net: str
destination_port: str
enabled: bool
quick: bool
log: bool
class FirewallRuleUpdateDict(TypedDict):
uuid: str
rule: FirewallRuleDict
changed_fields: List[str]
class FirewallRuleDiffDict(TypedDict):
add: List[FirewallRuleDict]
update: List[FirewallRuleUpdateDict]
class VPNTunnelDict(TypedDict):
type: str
local_endpoint: str