feat: port forwards are a firewall reader too, and only the WAN's

get_port_forwards was declared on ResidentialGatewayDriver alone, as if a
port forward were a home-router feature. A firewall forwards ports just the
same (OPNsense calls it destination NAT), and netOrk asks both: is this host
reachable from the internet, which CVEs are exposed. The declaration moves to
NatVpnMixin, where the two roles already overlap, and PortForwardDict next to
NATTranslationDict.

The contract now says what counts. Destination NAT between internal networks
and rules that only exempt traffic are not port forwards: callers read every
entry as "reachable from outside". "ANY" forwards every protocol and an
external port of 0 every port -- a whole host forwarded is the most exposed
case and must not fall out for lack of a port number.

Declaration only, under TYPE_CHECKING: nothing changes at runtime.
This commit is contained in:
2026-10-03 16:30:32 +02:00
parent 7b491164a2
commit 31949eca0a
5 changed files with 105 additions and 46 deletions
+5
View File
@@ -227,6 +227,11 @@ class PfSenseDriver(FirewallDriver):
def get_vpn_tunnels(self):
# return Dict[str, VPNTunnelDict]
...
def get_port_forwards(self):
# return List[PortForwardDict] — forwards from the WAN only, never a
# redirect between internal networks (shared with home gateways)
...
```
### Hypervisor
+15 -10
View File
@@ -298,6 +298,21 @@ class NATTranslationDict(TypedDict):
age: float
class PortForwardDict(TypedDict):
"""A port the WAN side can reach, forwarded to a host inside.
Shared by firewalls and home gateways (``NatVpnMixin.get_port_forwards``).
"""
name: str
protocol: str # "TCP" or "UDP"
external_port: int
internal_ip: str
internal_port: int
enabled: bool
remote_host: NotRequired[str] # restrict forward to a specific remote source
class SecurityZoneDict(TypedDict):
interfaces: List[str]
policy: str
@@ -470,16 +485,6 @@ class WANStatusDict(TypedDict):
link_status: NotRequired[str] # physical line state, e.g. "Up" / "Down"
class PortForwardDict(TypedDict):
name: str
protocol: str # "TCP" or "UDP"
external_port: int
internal_ip: str
internal_port: int
enabled: bool
remote_host: NotRequired[str] # restrict forward to a specific remote source
class HostDict(TypedDict):
mac: str
ip: str
+43 -3
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""Address translation and VPN tunnels.
A home gateway does a subset of what a firewall does, and these two readers
are where the sets overlap exactly.
A home gateway does a subset of what a firewall does, and these readers are
where the sets overlap exactly.
Declared under ``if TYPE_CHECKING``: these are contracts, not placeholders.
Nothing exists at runtime until a concrete driver implements it, so mixing
@@ -13,7 +13,7 @@ from __future__ import annotations
from typing import Dict, List, TYPE_CHECKING
from napalm_device_types.models import NATTranslationDict, VPNTunnelDict
from napalm_device_types.models import NATTranslationDict, PortForwardDict, VPNTunnelDict
class NatVpnMixin:
@@ -47,6 +47,46 @@ class NatVpnMixin:
"""
...
def get_port_forwards(self) -> List[PortForwardDict]:
"""
Returns the port forwards that let traffic in from the WAN.
A port forward here means destination NAT on an interface facing
the internet: whoever reaches the external port is let through to
``internal_ip``. A redirect between internal networks is
destination NAT as well, but it is **not** a port forward and must
be left out -- callers read every entry as "this host is reachable
from outside". So are rules that only exempt traffic from
redirection.
Each entry contains:
* name (string) - the rule's description/name
* protocol (string) - ``"TCP"`` or ``"UDP"``; a rule for both is
two entries. ``"ANY"`` forwards every protocol
* external_port (int) - the WAN-side port; the first of a range,
``0`` for every port (a whole host forwarded)
* internal_ip (string) - the host the traffic is forwarded to
* internal_port (int) - the port on that host
* enabled (bool) - whether the rule is currently active
* remote_host (string, optional) - restricts the forward to a specific
remote source address; empty/absent means "any"
Example::
[
{
"name": "Webserver HTTPS",
"protocol": "TCP",
"external_port": 443,
"internal_ip": "192.168.1.10",
"internal_port": 443,
"enabled": True,
}
]
"""
...
def get_vpn_tunnels(self) -> Dict[str, VPNTunnelDict]:
"""
Returns the status of VPN tunnels.
+3 -33
View File
@@ -6,8 +6,9 @@ wireless access point in a single consumer device (e.g. AVM FritzBox,
ISP-supplied DSL/cable routers). This base class merges the relevant
subsets of :class:`~napalm_device_types.firewall.FirewallDriver` and
:class:`~napalm_device_types.access_point.AccessPointDriver` plus
gateway-specific operations (WAN status, port forwarding, connected
hosts).
gateway-specific operations (WAN status, connected hosts). Port
forwarding is shared with firewalls, in
:class:`~napalm_device_types.nat_vpn.NatVpnMixin`.
Usage::
@@ -25,7 +26,6 @@ from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.dhcp import DhcpServerMixin
from napalm_device_types.models import (
HostDict,
PortForwardDict,
RadioStatusDict,
SSIDDict,
WANStatusDict,
@@ -85,36 +85,6 @@ class ResidentialGatewayDriver(NatVpnMixin, HealthMetricsMixin, DhcpServerMixin,
"""
...
def get_port_forwards(self) -> List[PortForwardDict]:
"""
Returns the configured port forwarding (port mapping) rules.
Each entry contains:
* name (string) - the rule's description/name
* protocol (string) - ``"TCP"`` or ``"UDP"``
* external_port (int) - the WAN-side port
* internal_ip (string) - the LAN host the traffic is forwarded to
* internal_port (int) - the LAN-side port
* enabled (bool) - whether the rule is currently active
* remote_host (string, optional) - restricts the forward to a specific
remote source address; empty/absent means "any"
Example::
[
{
"name": "Webserver HTTPS",
"protocol": "TCP",
"external_port": 443,
"internal_ip": "192.168.1.10",
"internal_port": 443,
"enabled": True,
}
]
"""
...
def get_hosts(self) -> List[HostDict]:
"""
Returns the list of hosts known to the gateway (LAN clients).
+39
View File
@@ -0,0 +1,39 @@
"""get_port_forwards: what the WAN side may reach inside, on any gateway.
The reader used to be declared on ``ResidentialGatewayDriver`` only, as if a
port forward were a home-router feature. A firewall forwards ports just the
same -- OPNsense calls it destination NAT -- and the two consumers that ask
(is this host reachable from the internet, which CVEs are exposed) need the
answer from both. The declaration therefore lives where the two roles overlap,
next to the NAT translations reader.
"""
from __future__ import annotations
import inspect
from napalm_device_types import FirewallDriver, ResidentialGatewayDriver
from napalm_device_types.nat_vpn import NatVpnMixin
def test_a_firewall_and_a_gateway_share_the_declaration():
assert issubclass(FirewallDriver, NatVpnMixin)
assert issubclass(ResidentialGatewayDriver, NatVpnMixin)
assert "def get_port_forwards(self) -> List[PortForwardDict]" in inspect.getsource(NatVpnMixin)
def test_it_is_declared_once():
assert "def get_port_forwards" not in inspect.getsource(ResidentialGatewayDriver)
def test_absent_until_a_driver_implements_it():
assert not hasattr(FirewallDriver, "get_port_forwards")
assert not hasattr(ResidentialGatewayDriver, "get_port_forwards")
def test_the_contract_says_what_counts():
"""A redirect between two internal networks is destination NAT too, and
would make an internal host look reachable from the internet."""
source = inspect.getsource(NatVpnMixin)
assert "from the WAN" in source
assert "between internal networks" in source