diff --git a/README.md b/README.md index 765e55c..ecb345e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/napalm_device_types/models.py b/napalm_device_types/models.py index 3f9d67b..5802502 100644 --- a/napalm_device_types/models.py +++ b/napalm_device_types/models.py @@ -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 diff --git a/napalm_device_types/nat_vpn.py b/napalm_device_types/nat_vpn.py index c021829..5d8328a 100644 --- a/napalm_device_types/nat_vpn.py +++ b/napalm_device_types/nat_vpn.py @@ -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. diff --git a/napalm_device_types/residential_gateway.py b/napalm_device_types/residential_gateway.py index 41fee80..369bb7a 100644 --- a/napalm_device_types/residential_gateway.py +++ b/napalm_device_types/residential_gateway.py @@ -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). diff --git a/tests/test_port_forwards.py b/tests/test_port_forwards.py new file mode 100644 index 0000000..7807d39 --- /dev/null +++ b/tests/test_port_forwards.py @@ -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