get_system_config() already parsed the whole system section but returned
only a slice of it, so netOrk had no IST side for three AP-profile fields
and could not compare them at all — the fields were editable, stored and
silently ineffective.
Adds:
* syslog_remote / syslog_ip / syslog_port / syslog_proto — read from the
system section that was already being parsed. log_remote gates the
others: OpenWrt ships nothing without it, so a leftover log_ip must
not read as an active target.
* luci_enabled — from the uhttpd init script rather than its listener
config, so the answer stays "is the web UI served" and re-enabling
restores whatever was configured before.
* bridge_stp — None when there is no br-ap at all, which is a different
statement from "bridge without STP" and has to stay distinguishable.
netOrk #164
1870 lines
68 KiB
Python
1870 lines
68 KiB
Python
"""Unit tests for OpenWrtDriver — no real device required."""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from napalm_openwrt.openwrt import OpenWrtDriver
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def driver():
|
|
"""Return a driver instance with a mocked Netmiko connection."""
|
|
with patch("napalm_openwrt.openwrt.ConnectHandler"):
|
|
drv = OpenWrtDriver(
|
|
hostname="192.168.1.1",
|
|
username="root",
|
|
password="",
|
|
)
|
|
drv.device = MagicMock()
|
|
yield drv
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample command output (as would be returned by the device)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
OPENWRT_RELEASE = """\
|
|
DISTRIB_ID="OpenWrt"
|
|
DISTRIB_RELEASE="23.05.3"
|
|
DISTRIB_REVISION="r23809-234f1a2efa"
|
|
DISTRIB_TARGET="ath79/generic"
|
|
DISTRIB_ARCH="mips_24kc"
|
|
DISTRIB_CODENAME="Restoring Earth"
|
|
DISTRIB_TAINTS=""
|
|
"""
|
|
|
|
SYSINFO_MODEL = "TP-Link TL-WR1043N/ND v5"
|
|
|
|
UPTIME = "352467.12 345823.44"
|
|
|
|
IP_LINK_SHOW = """\
|
|
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
|
|
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
|
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
|
|
link/ether b0:95:75:aa:bb:cc brd ff:ff:ff:ff:ff:ff
|
|
3: eth1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
|
|
link/ether b0:95:75:aa:bb:dd brd ff:ff:ff:ff:ff:ff
|
|
4: br-lan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
|
|
link/ether b0:95:75:aa:bb:cc brd ff:ff:ff:ff:ff:ff
|
|
"""
|
|
|
|
IP_ADDR_SHOW = """\
|
|
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
|
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
|
inet 127.0.0.1/8 scope host lo
|
|
valid_lft forever preferred_lft forever
|
|
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
|
|
link/ether b0:95:75:aa:bb:cc brd ff:ff:ff:ff:ff:ff
|
|
4: br-lan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
|
|
link/ether b0:95:75:aa:bb:cc brd ff:ff:ff:ff:ff:ff
|
|
inet 192.168.1.1/24 brd 192.168.1.255 scope global br-lan
|
|
valid_lft forever preferred_lft forever
|
|
inet6 fd00::1/64 scope global
|
|
valid_lft forever preferred_lft forever
|
|
"""
|
|
|
|
IP_NEIGH_SHOW = """\
|
|
192.168.1.100 dev br-lan lladdr aa:bb:cc:dd:ee:ff REACHABLE
|
|
192.168.1.101 dev br-lan lladdr 11:22:33:44:55:66 STALE
|
|
192.168.1.102 dev br-lan FAILED
|
|
"""
|
|
|
|
BRIDGE_FDB = """\
|
|
aa:bb:cc:dd:ee:ff dev br-lan master br-lan permanent
|
|
11:22:33:44:55:66 dev eth0 vlan 1 master br-lan
|
|
33:33:00:00:00:01 dev br-lan self permanent
|
|
"""
|
|
|
|
UCI_EXPORT = """\
|
|
package system
|
|
|
|
config system
|
|
\toption hostname 'OpenWrt'
|
|
\toption timezone 'UTC'
|
|
|
|
package network
|
|
|
|
config interface 'loopback'
|
|
\toption device 'lo'
|
|
\toption proto 'static'
|
|
\toption ipaddr '127.0.0.1'
|
|
\toption netmask '255.0.0.0'
|
|
|
|
config interface 'lan'
|
|
\toption device 'br-lan'
|
|
\toption proto 'static'
|
|
\toption ipaddr '192.168.1.1'
|
|
\toption netmask '255.255.255.0'
|
|
"""
|
|
|
|
LLDPCTL_KV = """\
|
|
lldp.eth0.via=LLDP
|
|
lldp.eth0.rid=1
|
|
lldp.eth0.age=0 day, 01:23:45
|
|
lldp.eth0.chassis.mac=00:aa:bb:cc:dd:ee
|
|
lldp.eth0.chassis.name=core-router
|
|
lldp.eth0.chassis.descr=RouterOS 7.x
|
|
lldp.eth0.chassis.mgmt-ip=10.0.0.1
|
|
lldp.eth0.chassis.cap.available=Router, Bridge
|
|
lldp.eth0.chassis.cap.enabled=Router
|
|
lldp.eth0.port.ifname=ether1
|
|
lldp.eth0.port.descr=uplink
|
|
"""
|
|
|
|
PROC_NET_DEV = """\
|
|
Inter-| Receive | Transmit
|
|
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
|
lo: 1234 12 0 0 0 0 0 0 1234 12 0 0 0 0 0 0
|
|
eth0: 9876543 12345 0 0 0 0 0 100 1234567 9876 0 0 0 0 0 0
|
|
br-lan: 8765432 11234 0 0 0 0 0 50 1123456 8765 0 0 0 0 0 0
|
|
"""
|
|
|
|
HOSTNAME = "OpenWrt"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetFacts:
|
|
def _make_send(self):
|
|
"""Return a _send_command mock that handles both str and list args."""
|
|
def _send(cmd, **kw):
|
|
key = cmd[0] if isinstance(cmd, list) else cmd
|
|
if "openwrt_release" in key:
|
|
return OPENWRT_RELEASE
|
|
if "sysinfo/model" in key:
|
|
return SYSINFO_MODEL
|
|
if "uptime" in key:
|
|
return UPTIME
|
|
if "hostname" in key.lower() or "system.@system" in key:
|
|
return HOSTNAME
|
|
if "ip link" in key:
|
|
return IP_LINK_SHOW
|
|
return ""
|
|
return _send
|
|
|
|
def test_returns_required_keys(self, driver):
|
|
driver._send_command = self._make_send()
|
|
|
|
facts = driver.get_facts()
|
|
assert set(facts.keys()) == {
|
|
"vendor", "model", "hostname", "fqdn", "os_version",
|
|
"serial_number", "uptime", "interface_list", "number_of_interfaces",
|
|
}
|
|
|
|
def test_number_of_interfaces_matches_list(self, driver):
|
|
driver._send_command = self._make_send()
|
|
facts = driver.get_facts()
|
|
assert facts["number_of_interfaces"] == len(facts["interface_list"])
|
|
|
|
def test_vendor(self, driver):
|
|
driver._send_command = self._make_send()
|
|
assert driver.get_facts()["vendor"] == "OpenWrt"
|
|
|
|
def test_os_version(self, driver):
|
|
driver._send_command = self._make_send()
|
|
assert driver.get_facts()["os_version"] == "23.05.3"
|
|
|
|
def test_uptime_parsed(self, driver):
|
|
driver._send_command = self._make_send()
|
|
facts = driver.get_facts()
|
|
assert facts["uptime"] == pytest.approx(352467.12)
|
|
|
|
|
|
class TestParseOpenwrtRelease:
|
|
def test_parses_release(self):
|
|
result = OpenWrtDriver._parse_openwrt_release(OPENWRT_RELEASE)
|
|
assert result["DISTRIB_RELEASE"] == "23.05.3"
|
|
assert result["DISTRIB_ID"] == "OpenWrt"
|
|
assert result["DISTRIB_TARGET"] == "ath79/generic"
|
|
|
|
|
|
class TestGetInterfaces:
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_LINK_SHOW
|
|
result = driver.get_interfaces()
|
|
assert isinstance(result, dict)
|
|
|
|
def test_eth0_is_up(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_LINK_SHOW
|
|
result = driver.get_interfaces()
|
|
assert "eth0" in result
|
|
assert result["eth0"]["is_up"] is True
|
|
assert result["eth0"]["is_enabled"] is True
|
|
|
|
def test_eth1_is_down(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_LINK_SHOW
|
|
result = driver.get_interfaces()
|
|
assert "eth1" in result
|
|
assert result["eth1"]["is_up"] is False
|
|
|
|
def test_mac_address_populated(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_LINK_SHOW
|
|
result = driver.get_interfaces()
|
|
assert result["eth0"]["mac_address"] != ""
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_LINK_SHOW
|
|
result = driver.get_interfaces()
|
|
for iface_data in result.values():
|
|
assert set(iface_data.keys()) >= {
|
|
"is_up", "is_enabled", "description",
|
|
"last_flapped", "speed", "mtu", "mac_address",
|
|
}
|
|
|
|
|
|
class TestGetInterfacesIP:
|
|
def test_br_lan_ipv4(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ADDR_SHOW
|
|
result = driver.get_interfaces_ip()
|
|
assert "br-lan" in result
|
|
assert "192.168.1.1" in result["br-lan"]["ipv4"]
|
|
assert result["br-lan"]["ipv4"]["192.168.1.1"]["prefix_length"] == 24
|
|
|
|
def test_br_lan_ipv6(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ADDR_SHOW
|
|
result = driver.get_interfaces_ip()
|
|
assert "ipv6" in result["br-lan"]
|
|
assert "fd00::1" in result["br-lan"]["ipv6"]
|
|
|
|
|
|
class TestGetArpTable:
|
|
def test_returns_list(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_NEIGH_SHOW
|
|
result = driver.get_arp_table()
|
|
assert isinstance(result, list)
|
|
|
|
def test_entries_count(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_NEIGH_SHOW
|
|
result = driver.get_arp_table()
|
|
# FAILED entry should be skipped
|
|
assert len(result) == 2
|
|
|
|
def test_entry_keys(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_NEIGH_SHOW
|
|
result = driver.get_arp_table()
|
|
for entry in result:
|
|
assert set(entry.keys()) >= {"interface", "mac", "ip", "age"}
|
|
|
|
def test_ip_value(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_NEIGH_SHOW
|
|
result = driver.get_arp_table()
|
|
ips = {e["ip"] for e in result}
|
|
assert "192.168.1.100" in ips
|
|
assert "192.168.1.101" in ips
|
|
|
|
|
|
class TestGetMacAddressTable:
|
|
def test_returns_list(self, driver):
|
|
driver._send_command = lambda cmd, **kw: BRIDGE_FDB
|
|
result = driver.get_mac_address_table()
|
|
assert isinstance(result, list)
|
|
|
|
def test_multicast_skipped(self, driver):
|
|
driver._send_command = lambda cmd, **kw: BRIDGE_FDB
|
|
result = driver.get_mac_address_table()
|
|
macs = [e["mac"] for e in result]
|
|
assert not any("33:33" in m for m in macs)
|
|
|
|
def test_vlan_parsed(self, driver):
|
|
driver._send_command = lambda cmd, **kw: BRIDGE_FDB
|
|
result = driver.get_mac_address_table()
|
|
vlan1_entries = [e for e in result if e["vlan"] == 1]
|
|
assert len(vlan1_entries) >= 1
|
|
|
|
|
|
class TestGetConfig:
|
|
def test_returns_uci_export(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_EXPORT
|
|
result = driver.get_config()
|
|
assert "running" in result
|
|
assert "startup" in result
|
|
assert "candidate" in result
|
|
assert "package" in result["running"]
|
|
|
|
def test_candidate_always_empty(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_EXPORT
|
|
result = driver.get_config()
|
|
assert result["candidate"] == ""
|
|
|
|
|
|
class TestGetLldpNeighbors:
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = lambda cmd, **kw: LLDPCTL_KV
|
|
result = driver.get_lldp_neighbors()
|
|
assert isinstance(result, dict)
|
|
|
|
def test_eth0_neighbor(self, driver):
|
|
driver._send_command = lambda cmd, **kw: LLDPCTL_KV
|
|
result = driver.get_lldp_neighbors()
|
|
assert "eth0" in result
|
|
assert result["eth0"][0]["hostname"] == "core-router"
|
|
assert result["eth0"][0]["port"] == "ether1"
|
|
|
|
|
|
BRIDGE_VLAN_SHOW = """\
|
|
port vlan-id
|
|
eth0 1 PVID Egress Untagged
|
|
10
|
|
20
|
|
br-lan 1 PVID Egress Untagged
|
|
10
|
|
20
|
|
eth1 20 PVID Egress Untagged
|
|
"""
|
|
|
|
UCI_NETWORK_VLANS = """\
|
|
network.@bridge-vlan[0]=bridge-vlan
|
|
network.@bridge-vlan[0].device='br-lan'
|
|
network.@bridge-vlan[0].vlan='10'
|
|
network.@bridge-vlan[0].name='management'
|
|
network.@bridge-vlan[1]=bridge-vlan
|
|
network.@bridge-vlan[1].device='br-lan'
|
|
network.@bridge-vlan[1].vlan='20'
|
|
network.@bridge-vlan[1].name='iot'
|
|
"""
|
|
|
|
|
|
class TestGetVlans:
|
|
def _send(self, cmd, **kw):
|
|
if "bridge vlan" in cmd:
|
|
return BRIDGE_VLAN_SHOW
|
|
if "uci show network" in cmd:
|
|
return UCI_NETWORK_VLANS
|
|
return ""
|
|
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert isinstance(result, dict)
|
|
|
|
def test_vlan_ids_present(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert "1" in result
|
|
assert "10" in result
|
|
assert "20" in result
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
for vlan_data in result.values():
|
|
assert "name" in vlan_data
|
|
assert "tagged" in vlan_data
|
|
assert "untagged" in vlan_data
|
|
|
|
def test_pvid_port_is_untagged_member(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert set(result["1"]["untagged"]) == {"eth0", "br-lan"}
|
|
assert result["1"]["tagged"] == []
|
|
|
|
def test_continuation_lines_are_tagged_members(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert set(result["10"]["tagged"]) == {"eth0", "br-lan"}
|
|
assert result["10"]["untagged"] == []
|
|
|
|
def test_same_vlan_can_mix_tagged_and_untagged_ports(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert set(result["20"]["tagged"]) == {"eth0", "br-lan"}
|
|
assert set(result["20"]["untagged"]) == {"eth1"}
|
|
|
|
def test_uci_names_applied(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert result["10"]["name"] == "management"
|
|
assert result["20"]["name"] == "iot"
|
|
|
|
def test_vlan_without_uci_name_is_empty_string(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert result["1"]["name"] == ""
|
|
|
|
def test_no_duplicate_ports(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
for vlan_data in result.values():
|
|
for key in ("tagged", "untagged"):
|
|
assert len(vlan_data[key]) == len(set(vlan_data[key]))
|
|
|
|
def test_port_is_never_both_tagged_and_untagged(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
for vlan_data in result.values():
|
|
assert not set(vlan_data["tagged"]) & set(vlan_data["untagged"])
|
|
|
|
def test_empty_bridge_output(self, driver):
|
|
driver._send_command = lambda cmd, **kw: ""
|
|
result = driver.get_vlans()
|
|
assert result == {}
|
|
|
|
|
|
# A VLAN-aware bridge as configured by an AP profile: management VLAN untagged
|
|
# (native/PVID) on the uplink, SSID VLANs tagged. Sections are *named*, which
|
|
# is what OpenWrt writes for a hand-built or tool-provisioned bridge.
|
|
UCI_NAMED_BRIDGE_VLANS = """\
|
|
network.apbr=device
|
|
network.apbr.name='br-ap'
|
|
network.apbr.type='bridge'
|
|
network.apbr.ports='eth0'
|
|
network.apbr.vlan_filtering='1'
|
|
network.apbr_vlan10=bridge-vlan
|
|
network.apbr_vlan10.device='br-ap'
|
|
network.apbr_vlan10.vlan='10'
|
|
network.apbr_vlan10.ports='eth0:u*'
|
|
network.apbr_vlan30=bridge-vlan
|
|
network.apbr_vlan30.device='br-ap'
|
|
network.apbr_vlan30.vlan='30'
|
|
network.apbr_vlan30.ports='eth0:t'
|
|
network.mgmt=interface
|
|
network.mgmt.device='br-ap.10'
|
|
network.mgmt.proto='dhcp'
|
|
network.esche=interface
|
|
network.esche.device='br-ap.30'
|
|
network.esche.proto='none'
|
|
"""
|
|
|
|
IP_LINK_AP = """\
|
|
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue
|
|
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel master br-ap
|
|
3: br-ap: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue
|
|
4: br-ap.10@br-ap: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue
|
|
5: br-ap.30@br-ap: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue
|
|
"""
|
|
|
|
|
|
class TestGetVlansWithoutBridgeBinary:
|
|
"""Membership must come from UCI when `bridge` is absent (BusyBox-only APs).
|
|
|
|
Devices such as the Sophos AP100 ship BusyBox without the `bridge`/`ip-full`
|
|
packages, so `bridge vlan show` fails. Falling back to sub-interface
|
|
topology alone reports `br-ap`/`br-ap.10` and never names the uplink port,
|
|
which is the only port whose tagging actually matters.
|
|
"""
|
|
|
|
def _send(self, cmd, **kw):
|
|
if "bridge vlan" in cmd:
|
|
return "ash: bridge: not found"
|
|
if "uci show network" in cmd:
|
|
return UCI_NAMED_BRIDGE_VLANS
|
|
if "ip link show" in cmd:
|
|
return IP_LINK_AP
|
|
return ""
|
|
|
|
def test_untagged_pvid_uplink_membership(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert "eth0" in result["10"]["untagged"]
|
|
assert "eth0" not in result["10"]["tagged"]
|
|
|
|
def test_tagged_uplink_membership(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert "eth0" in result["30"]["tagged"]
|
|
assert "eth0" not in result["30"]["untagged"]
|
|
|
|
def test_named_sections_are_parsed(self, driver):
|
|
"""The old parser only matched anonymous @bridge-vlan[N] sections."""
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert {"10", "30"} <= set(result)
|
|
|
|
def test_shell_error_is_not_parsed_as_membership(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
for vlan_data in result.values():
|
|
assert "ash:" not in vlan_data["tagged"] + vlan_data["untagged"]
|
|
|
|
def test_name_falls_back_to_interface_section(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_vlans()
|
|
assert result["10"]["name"] == "mgmt"
|
|
assert result["30"]["name"] == "esche"
|
|
|
|
def test_runtime_membership_wins_over_uci(self, driver):
|
|
"""`bridge vlan show` describes what the kernel actually does."""
|
|
|
|
def _send(cmd, **kw):
|
|
if "bridge vlan" in cmd:
|
|
return "port vlan-id\neth0 10 PVID Egress Untagged\n"
|
|
if "uci show network" in cmd:
|
|
# UCI claims tagged; the kernel says untagged.
|
|
return (
|
|
"network.apbr_vlan10=bridge-vlan\n"
|
|
"network.apbr_vlan10.device='br-ap'\n"
|
|
"network.apbr_vlan10.vlan='10'\n"
|
|
"network.apbr_vlan10.ports='eth0:t'\n"
|
|
)
|
|
return ""
|
|
|
|
driver._send_command = _send
|
|
result = driver.get_vlans()
|
|
assert "eth0" in result["10"]["untagged"]
|
|
assert "eth0" not in result["10"]["tagged"]
|
|
|
|
def test_multi_port_list_is_split(self, driver):
|
|
def _send(cmd, **kw):
|
|
if "uci show network" in cmd:
|
|
return (
|
|
"network.brv=bridge-vlan\n"
|
|
"network.brv.device='br-lan'\n"
|
|
"network.brv.vlan='20'\n"
|
|
"network.brv.ports='lan1:t' 'lan2:t' 'lan3'\n"
|
|
)
|
|
return ""
|
|
|
|
driver._send_command = _send
|
|
result = driver.get_vlans()
|
|
assert set(result["20"]["tagged"]) == {"lan1", "lan2"}
|
|
assert set(result["20"]["untagged"]) == {"lan3"}
|
|
|
|
|
|
class TestConfigManagement:
|
|
def test_load_merge_candidate(self, driver):
|
|
driver.load_merge_candidate(config="uci set system.@system[0].hostname='MyRouter'")
|
|
assert driver._candidate_config is not None
|
|
assert driver._candidate_mode == "merge"
|
|
|
|
def test_load_replace_candidate(self, driver):
|
|
driver.load_replace_candidate(config=UCI_EXPORT)
|
|
assert driver._candidate_config is not None
|
|
assert driver._candidate_mode == "replace"
|
|
|
|
def test_discard_config(self, driver):
|
|
driver.load_merge_candidate(config="uci set system.@system[0].hostname='test'")
|
|
driver.discard_config()
|
|
assert driver._candidate_config is None
|
|
assert not driver.has_pending_commit()
|
|
|
|
def test_compare_merge_candidate(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_EXPORT
|
|
driver.load_merge_candidate(config="uci set system.@system[0].hostname='test'")
|
|
diff = driver.compare_config()
|
|
assert diff.startswith("+")
|
|
|
|
def test_compare_no_candidate(self, driver):
|
|
assert driver.compare_config() == ""
|
|
|
|
def test_has_pending_commit_false_initially(self, driver):
|
|
assert not driver.has_pending_commit()
|
|
|
|
def test_has_pending_commit_true_after_load(self, driver):
|
|
driver.load_merge_candidate(config="uci set system.@system[0].hostname='test'")
|
|
assert driver.has_pending_commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample data for new methods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
PASSWD = """\
|
|
root:$1$xyz:0:0:root:/root:/bin/ash
|
|
daemon:*:1:1:daemon:/var:/bin/false
|
|
nobody:*:65534:65534:nobody:/var:/bin/false
|
|
alice:$6$abc:1001:1001:Alice:/home/alice:/bin/ash
|
|
"""
|
|
|
|
ROOT_AUTHORIZED_KEYS = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABroot@host"
|
|
ALICE_AUTHORIZED_KEYS = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5alice@host"
|
|
|
|
UCI_SYSTEM_NTP = """\
|
|
system.@system[0]=system
|
|
system.@system[0].hostname='OpenWrt'
|
|
system.@system[0].timezone='UTC'
|
|
system.ntp=timeserver
|
|
system.ntp.server='0.openwrt.pool.ntp.org 1.openwrt.pool.ntp.org 2.openwrt.pool.ntp.org'
|
|
system.ntp.enabled='1'
|
|
system.ntp.enable_server='0'
|
|
"""
|
|
|
|
NTPQ_OUTPUT = """\
|
|
remote refid st t when poll reach delay offset jitter
|
|
==============================================================================
|
|
*188.114.101.4 188.114.100.1 4 u 107 256 377 164.228 -13.866 2.695
|
|
+37.187.56.220 192.53.103.108 2 u 22 64 377 30.112 5.123 1.100
|
|
"""
|
|
|
|
UCI_SNMPD = """\
|
|
snmpd.@agent[0]=agent
|
|
snmpd.@agent[0].agentaddress='UDP:161'
|
|
snmpd.@com2sec[0]=com2sec
|
|
snmpd.@com2sec[0].secname='public'
|
|
snmpd.@com2sec[0].source='default'
|
|
snmpd.@com2sec[0].community='public'
|
|
snmpd.@com2sec[1]=com2sec
|
|
snmpd.@com2sec[1].secname='private'
|
|
snmpd.@com2sec[1].source='10.0.0.0/8'
|
|
snmpd.@com2sec[1].community='private'
|
|
snmpd.@system[0]=system
|
|
snmpd.@system[0].sysContact='admin@example.com'
|
|
snmpd.@system[0].sysLocation='Server Room'
|
|
snmpd.@system[0].sysName='MyRouter'
|
|
"""
|
|
|
|
PING_SUCCESS = """\
|
|
PING 8.8.8.8 (8.8.8.8): 56 data bytes
|
|
64 bytes from 8.8.8.8: seq=0 ttl=120 time=7.123 ms
|
|
64 bytes from 8.8.8.8: seq=1 ttl=120 time=6.987 ms
|
|
64 bytes from 8.8.8.8: seq=2 ttl=120 time=7.234 ms
|
|
--- 8.8.8.8 ping statistics ---
|
|
3 packets transmitted, 3 packets received, 0% packet loss
|
|
round-trip min/avg/max = 6.987/7.115/7.234 ms
|
|
"""
|
|
|
|
PING_LOSS = """\
|
|
PING 10.0.0.99 (10.0.0.99): 56 data bytes
|
|
--- 10.0.0.99 ping statistics ---
|
|
3 packets transmitted, 0 packets received, 100% packet loss
|
|
"""
|
|
|
|
PING_ERROR = "ping: bad address 'invalid.host'"
|
|
|
|
IPV6_NEIGH = """\
|
|
2001:db8::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
|
|
fe80::1 dev br-lan lladdr 11:22:33:44:55:66 STALE
|
|
2001:db8::2 dev eth0 FAILED
|
|
"""
|
|
|
|
IP_ROUTE_SHOW = """\
|
|
default via 192.168.1.1 dev br-wan proto dhcp src 203.0.113.1 metric 100
|
|
192.168.1.0/24 dev br-lan proto kernel scope link src 192.168.1.1
|
|
10.0.0.0/8 via 192.168.1.254 dev br-wan proto static metric 50
|
|
"""
|
|
|
|
TRACEROUTE_OUTPUT = """\
|
|
traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 38 byte packets
|
|
1 192.168.1.1 (192.168.1.1) 1.123 ms 1.456 ms 1.789 ms
|
|
2 10.0.0.1 (10.0.0.1) 5.123 ms 5.456 ms 5.789 ms
|
|
3 * * *
|
|
4 8.8.8.8 (8.8.8.8) 7.001 ms 6.999 ms 7.100 ms
|
|
"""
|
|
|
|
TRACEROUTE_ERROR = "traceroute: unknown host invalid.host"
|
|
|
|
IP_NETNS_LIST = """\
|
|
vpn (id: 1)
|
|
mgmt (id: 2)
|
|
"""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for new methods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCli:
|
|
def test_returns_dict_keyed_by_command(self, driver):
|
|
driver._send_command = lambda cmd, **kw: f"output of {cmd}"
|
|
result = driver.cli(["uname -a", "uptime"])
|
|
assert set(result.keys()) == {"uname -a", "uptime"}
|
|
|
|
def test_output_content(self, driver):
|
|
driver._send_command = lambda cmd, **kw: "Linux OpenWrt"
|
|
result = driver.cli(["uname -a"])
|
|
assert result["uname -a"] == "Linux OpenWrt"
|
|
|
|
def test_empty_command_list(self, driver):
|
|
result = driver.cli([])
|
|
assert result == {}
|
|
|
|
|
|
class TestGetUsers:
|
|
def _send(self, cmd, **kw):
|
|
key = cmd[0] if isinstance(cmd, list) else cmd
|
|
if "/etc/passwd" in key:
|
|
return PASSWD
|
|
if "root/.ssh/authorized_keys" in key or "dropbear/authorized_keys" in key:
|
|
return ROOT_AUTHORIZED_KEYS
|
|
if "alice" in key:
|
|
return ALICE_AUTHORIZED_KEYS
|
|
return ""
|
|
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = self._send
|
|
assert isinstance(driver.get_users(), dict)
|
|
|
|
def test_root_level_15(self, driver):
|
|
driver._send_command = self._send
|
|
users = driver.get_users()
|
|
assert "root" in users
|
|
assert users["root"]["level"] == 15
|
|
|
|
def test_regular_user_level_1(self, driver):
|
|
driver._send_command = self._send
|
|
users = driver.get_users()
|
|
assert "alice" in users
|
|
assert users["alice"]["level"] == 1
|
|
|
|
def test_system_accounts_excluded(self, driver):
|
|
driver._send_command = self._send
|
|
users = driver.get_users()
|
|
assert "daemon" not in users
|
|
assert "nobody" not in users
|
|
|
|
def test_root_has_ssh_key(self, driver):
|
|
driver._send_command = self._send
|
|
users = driver.get_users()
|
|
assert len(users["root"]["sshkeys"]) >= 1
|
|
assert users["root"]["sshkeys"][0].startswith("ssh-rsa")
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = self._send
|
|
users = driver.get_users()
|
|
for data in users.values():
|
|
assert "level" in data
|
|
assert "password" in data
|
|
assert "sshkeys" in data
|
|
|
|
|
|
class TestGetNtpServers:
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_SYSTEM_NTP
|
|
result = driver.get_ntp_servers()
|
|
assert isinstance(result, dict)
|
|
|
|
def test_servers_present(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_SYSTEM_NTP
|
|
result = driver.get_ntp_servers()
|
|
assert "0.openwrt.pool.ntp.org" in result
|
|
assert "1.openwrt.pool.ntp.org" in result
|
|
assert "2.openwrt.pool.ntp.org" in result
|
|
|
|
def test_empty_when_no_ntp(self, driver):
|
|
driver._send_command = lambda cmd, **kw: ""
|
|
assert driver.get_ntp_servers() == {}
|
|
|
|
|
|
class TestGetNtpStats:
|
|
def test_returns_list(self, driver):
|
|
driver._send_command = lambda cmd, **kw: NTPQ_OUTPUT
|
|
result = driver.get_ntp_stats()
|
|
assert isinstance(result, list)
|
|
|
|
def test_synchronized_entry(self, driver):
|
|
driver._send_command = lambda cmd, **kw: NTPQ_OUTPUT
|
|
result = driver.get_ntp_stats()
|
|
synced = [e for e in result if e["synchronized"]]
|
|
assert len(synced) == 1
|
|
assert synced[0]["remote"] == "188.114.101.4"
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = lambda cmd, **kw: NTPQ_OUTPUT
|
|
result = driver.get_ntp_stats()
|
|
for entry in result:
|
|
assert set(entry.keys()) >= {
|
|
"remote", "referenceid", "synchronized", "stratum",
|
|
"type", "when", "hostpoll", "reachability", "delay", "offset", "jitter",
|
|
}
|
|
|
|
def test_empty_on_no_tool(self, driver):
|
|
driver._send_command = lambda cmd, **kw: "sh: ntpq: not found"
|
|
result = driver.get_ntp_stats()
|
|
assert result == []
|
|
|
|
|
|
class TestGetSnmpInformation:
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_SNMPD
|
|
result = driver.get_snmp_information()
|
|
assert isinstance(result, dict)
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_SNMPD
|
|
result = driver.get_snmp_information()
|
|
assert set(result.keys()) >= {"chassis_id", "community", "contact", "location"}
|
|
|
|
def test_contact_and_location(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_SNMPD
|
|
result = driver.get_snmp_information()
|
|
assert result["contact"] == "admin@example.com"
|
|
assert result["location"] == "Server Room"
|
|
|
|
def test_community_entries(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_SNMPD
|
|
result = driver.get_snmp_information()
|
|
assert "public" in result["community"]
|
|
assert "private" in result["community"]
|
|
|
|
def test_community_mode(self, driver):
|
|
driver._send_command = lambda cmd, **kw: UCI_SNMPD
|
|
result = driver.get_snmp_information()
|
|
assert result["community"]["public"]["mode"] == "ro"
|
|
assert result["community"]["private"]["mode"] == "rw"
|
|
|
|
|
|
class TestPing:
|
|
def test_success_result(self, driver):
|
|
driver._send_command = lambda cmd, **kw: PING_SUCCESS
|
|
result = driver.ping("8.8.8.8")
|
|
assert "success" in result
|
|
assert result["success"]["probes_sent"] == 3
|
|
assert result["success"]["packet_loss"] == 0
|
|
|
|
def test_rtt_values(self, driver):
|
|
driver._send_command = lambda cmd, **kw: PING_SUCCESS
|
|
result = driver.ping("8.8.8.8")
|
|
s = result["success"]
|
|
assert s["rtt_min"] == 6.987
|
|
assert s["rtt_max"] == 7.234
|
|
assert s["rtt_avg"] == 7.115
|
|
|
|
def test_probe_results(self, driver):
|
|
driver._send_command = lambda cmd, **kw: PING_SUCCESS
|
|
result = driver.ping("8.8.8.8")
|
|
assert len(result["success"]["results"]) == 3
|
|
assert result["success"]["results"][0]["ip_address"] == "8.8.8.8"
|
|
|
|
def test_100_percent_loss(self, driver):
|
|
driver._send_command = lambda cmd, **kw: PING_LOSS
|
|
result = driver.ping("10.0.0.99")
|
|
assert "success" in result
|
|
assert result["success"]["packet_loss"] == 3
|
|
|
|
def test_error_on_bad_host(self, driver):
|
|
driver._send_command = lambda cmd, **kw: PING_ERROR
|
|
result = driver.ping("invalid.host")
|
|
assert "error" in result
|
|
|
|
|
|
class TestGetIpv6NeighborsTable:
|
|
def test_returns_list(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IPV6_NEIGH
|
|
assert isinstance(driver.get_ipv6_neighbors_table(), list)
|
|
|
|
def test_failed_entries_excluded(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IPV6_NEIGH
|
|
result = driver.get_ipv6_neighbors_table()
|
|
assert len(result) == 2
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IPV6_NEIGH
|
|
for entry in driver.get_ipv6_neighbors_table():
|
|
assert set(entry.keys()) >= {"interface", "mac", "ip", "age", "state"}
|
|
|
|
def test_state_values(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IPV6_NEIGH
|
|
states = {e["state"] for e in driver.get_ipv6_neighbors_table()}
|
|
assert "REACHABLE" in states
|
|
assert "STALE" in states
|
|
|
|
|
|
class TestGetRouteTo:
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ROUTE_SHOW
|
|
assert isinstance(driver.get_route_to(), dict)
|
|
|
|
def test_default_route_present(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ROUTE_SHOW
|
|
result = driver.get_route_to()
|
|
assert "0.0.0.0/0" in result
|
|
|
|
def test_next_hop(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ROUTE_SHOW
|
|
result = driver.get_route_to()
|
|
default = result["0.0.0.0/0"][0]
|
|
assert default["next_hop"] == "192.168.1.1"
|
|
assert default["outgoing_interface"] == "br-wan"
|
|
|
|
def test_connected_route(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ROUTE_SHOW
|
|
result = driver.get_route_to()
|
|
assert "192.168.1.0/24" in result
|
|
assert result["192.168.1.0/24"][0]["protocol"] == "connected"
|
|
|
|
def test_protocol_filter(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ROUTE_SHOW
|
|
result = driver.get_route_to(protocol="static")
|
|
assert "10.0.0.0/8" in result
|
|
assert "192.168.1.0/24" not in result
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = lambda cmd, **kw: IP_ROUTE_SHOW
|
|
for prefix, routes in driver.get_route_to().items():
|
|
for route in routes:
|
|
assert set(route.keys()) >= {
|
|
"protocol", "current_active", "next_hop",
|
|
"outgoing_interface", "preference", "routing_table",
|
|
}
|
|
|
|
|
|
class TestTraceroute:
|
|
def test_success_result(self, driver):
|
|
driver._send_command = lambda cmd, **kw: TRACEROUTE_OUTPUT
|
|
result = driver.traceroute("8.8.8.8")
|
|
assert "success" in result
|
|
|
|
def test_hop_count(self, driver):
|
|
driver._send_command = lambda cmd, **kw: TRACEROUTE_OUTPUT
|
|
result = driver.traceroute("8.8.8.8")
|
|
assert len(result["success"]) == 4
|
|
|
|
def test_hop_1_rtt(self, driver):
|
|
driver._send_command = lambda cmd, **kw: TRACEROUTE_OUTPUT
|
|
result = driver.traceroute("8.8.8.8")
|
|
hop1 = result["success"][1]["probes"][1]
|
|
assert hop1["rtt"] == 1.123
|
|
assert hop1["ip_address"] == "192.168.1.1"
|
|
|
|
def test_star_hop(self, driver):
|
|
driver._send_command = lambda cmd, **kw: TRACEROUTE_OUTPUT
|
|
result = driver.traceroute("8.8.8.8")
|
|
hop3 = result["success"][3]["probes"][1]
|
|
assert hop3["ip_address"] == "*"
|
|
|
|
def test_error_on_unknown_host(self, driver):
|
|
driver._send_command = lambda cmd, **kw: TRACEROUTE_ERROR
|
|
result = driver.traceroute("invalid.host")
|
|
assert "error" in result
|
|
|
|
|
|
class TestGetNetworkInstances:
|
|
def test_default_instance_always_present(self, driver):
|
|
driver._send_command = lambda cmd, **kw: (
|
|
IP_LINK_SHOW if "ip link" in cmd else ""
|
|
)
|
|
result = driver.get_network_instances()
|
|
assert "default" in result
|
|
|
|
def test_default_instance_type(self, driver):
|
|
driver._send_command = lambda cmd, **kw: (
|
|
IP_LINK_SHOW if "ip link" in cmd else ""
|
|
)
|
|
result = driver.get_network_instances()
|
|
assert result["default"]["type"] == "DEFAULT_INSTANCE"
|
|
|
|
def test_default_interfaces_populated(self, driver):
|
|
driver._send_command = lambda cmd, **kw: (
|
|
IP_LINK_SHOW if "ip link" in cmd else ""
|
|
)
|
|
result = driver.get_network_instances()
|
|
ifaces = result["default"]["interfaces"]["interface"]
|
|
assert "eth0" in ifaces
|
|
|
|
def test_named_namespaces(self, driver):
|
|
def _send(cmd, **kw):
|
|
if "netns list" in cmd:
|
|
return IP_NETNS_LIST
|
|
if "netns exec" in cmd:
|
|
return "" # empty namespace
|
|
if "ip link" in cmd:
|
|
return IP_LINK_SHOW
|
|
return ""
|
|
driver._send_command = _send
|
|
result = driver.get_network_instances()
|
|
assert "vpn" in result
|
|
assert "mgmt" in result
|
|
assert result["vpn"]["type"] == "L3VRF"
|
|
|
|
def test_name_filter(self, driver):
|
|
driver._send_command = lambda cmd, **kw: (
|
|
IP_LINK_SHOW if "ip link" in cmd else ""
|
|
)
|
|
result = driver.get_network_instances(name="default")
|
|
assert list(result.keys()) == ["default"]
|
|
|
|
def test_required_keys(self, driver):
|
|
driver._send_command = lambda cmd, **kw: (
|
|
IP_LINK_SHOW if "ip link" in cmd else ""
|
|
)
|
|
for inst in driver.get_network_instances().values():
|
|
assert set(inst.keys()) >= {"name", "type", "state", "interfaces"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample data for get_channel_scan()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
IW_DEV_OUT = """\
|
|
phy#0
|
|
\tInterface wlan0
|
|
\t\tifindex 4
|
|
\t\twdev 0x1
|
|
\t\taddr aa:bb:cc:dd:ee:f0
|
|
\t\tssid HomeNet
|
|
\t\ttype AP
|
|
\t\tchannel 6 (2437 MHz), width: 20 MHz, center1: 2437 MHz
|
|
phy#1
|
|
\tInterface wlan1
|
|
\t\tifindex 5
|
|
\t\twdev 0x2
|
|
\t\taddr aa:bb:cc:dd:ee:f1
|
|
\t\tssid HomeNet
|
|
\t\ttype AP
|
|
\t\tchannel 36 (5180 MHz), width: 80 MHz, center1: 5210 MHz
|
|
\tInterface wlan2
|
|
\t\tifindex 6
|
|
\t\twdev 0x3
|
|
\t\taddr aa:bb:cc:dd:ee:f2
|
|
\t\tssid GuestNet
|
|
\t\ttype managed
|
|
"""
|
|
|
|
# Minimal iw scan output for wlan0 (2.4 GHz AP, ch 6 + ch 1)
|
|
IW_SCAN_WLAN0 = """\
|
|
BSS aa:bb:cc:dd:01:ff(on wlan0)
|
|
\tfreq: 2437
|
|
\tsignal: -72.00 dBm
|
|
\tSSID: NeighborNet1
|
|
\tHT operation:
|
|
\t\t * primary channel: 6
|
|
\t\t * secondary channel offset: no secondary
|
|
\t\t * STA channel width: 20 MHz
|
|
BSS aa:bb:cc:dd:02:ff(on wlan0)
|
|
\tfreq: 2412
|
|
\tsignal: -85.00 dBm
|
|
\tSSID: FarAway
|
|
\tHT operation:
|
|
\t\t * primary channel: 1
|
|
\t\t * secondary channel offset: no secondary
|
|
\t\t * STA channel width: 20 MHz
|
|
BSS aa:bb:cc:dd:03:ff(on wlan0)
|
|
\tfreq: 2452
|
|
\tsignal: -90.00 dBm
|
|
\tSSID: Hidden
|
|
\tHT operation:
|
|
\t\t * primary channel: 9
|
|
\t\t * secondary channel offset: no secondary
|
|
\t\t * STA channel width: 20 MHz
|
|
"""
|
|
|
|
# Minimal iw scan output for wlan1 (5 GHz AP, ch 36 + 44)
|
|
IW_SCAN_WLAN1 = """\
|
|
BSS 11:22:33:44:01:ff(on wlan1)
|
|
\tfreq: 5180
|
|
\tsignal: -58.00 dBm
|
|
\tSSID: Corp5G
|
|
\tVHT operation:
|
|
\t\t * channel width: 80 MHz
|
|
\t\t * center freq segment 1: 42
|
|
\t\t * center freq segment 2: 0
|
|
BSS 11:22:33:44:02:ff(on wlan1)
|
|
\tfreq: 5220
|
|
\tsignal: -77.00 dBm
|
|
\tSSID: Office5G
|
|
\tVHT operation:
|
|
\t\t * channel width: 80 MHz
|
|
"""
|
|
|
|
|
|
class TestGetChannelScan:
|
|
"""Tests for OpenWrtWirelessMixin.get_channel_scan()."""
|
|
|
|
def _make_send(self, mode: str = "active"):
|
|
"""Return a _send_command mock routing iw dev / iw scan output."""
|
|
def _send(cmd, **kw):
|
|
if cmd.strip().startswith("iw dev") and "scan" not in cmd:
|
|
return IW_DEV_OUT
|
|
if "wlan0" in cmd and "scan" in cmd:
|
|
return IW_SCAN_WLAN0
|
|
if "wlan1" in cmd and "scan" in cmd:
|
|
return IW_SCAN_WLAN1
|
|
return ""
|
|
return _send
|
|
|
|
def test_returns_dict(self, driver):
|
|
driver._send_command = self._make_send()
|
|
result = driver.get_channel_scan()
|
|
assert isinstance(result, dict)
|
|
|
|
def test_only_ap_interfaces_included(self, driver):
|
|
"""wlan2 is managed (client) mode — must not appear in results."""
|
|
driver._send_command = self._make_send()
|
|
result = driver.get_channel_scan()
|
|
assert "wlan0" in result
|
|
assert "wlan1" in result
|
|
assert "wlan2" not in result
|
|
|
|
def test_wlan0_entry_count(self, driver):
|
|
driver._send_command = self._make_send()
|
|
result = driver.get_channel_scan()
|
|
assert len(result["wlan0"]) == 3
|
|
|
|
def test_wlan0_entry_required_keys(self, driver):
|
|
driver._send_command = self._make_send()
|
|
entry = driver.get_channel_scan()["wlan0"][0]
|
|
assert set(entry.keys()) >= {
|
|
"bssid", "ssid", "frequency", "channel", "signal_dbm",
|
|
"channel_width", "band",
|
|
}
|
|
|
|
def test_wlan0_first_entry_values(self, driver):
|
|
driver._send_command = self._make_send()
|
|
entry = driver.get_channel_scan()["wlan0"][0]
|
|
assert entry["bssid"] == "aa:bb:cc:dd:01:ff"
|
|
assert entry["ssid"] == "NeighborNet1"
|
|
assert entry["frequency"] == 2437
|
|
assert entry["channel"] == 6
|
|
assert entry["signal_dbm"] == -72
|
|
assert entry["channel_width"] == 20
|
|
assert entry["band"] == "2.4GHz"
|
|
|
|
def test_wlan0_ch1_entry(self, driver):
|
|
driver._send_command = self._make_send()
|
|
entries = driver.get_channel_scan()["wlan0"]
|
|
ch1 = next(e for e in entries if e["channel"] == 1)
|
|
assert ch1["signal_dbm"] == -85
|
|
assert ch1["band"] == "2.4GHz"
|
|
|
|
def test_wlan0_hidden_ssid(self, driver):
|
|
"""Empty SSID (hidden) should be stored as empty string."""
|
|
driver._send_command = self._make_send()
|
|
entries = driver.get_channel_scan()["wlan0"]
|
|
ch9 = next(e for e in entries if e["channel"] == 9)
|
|
assert ch9["ssid"] == "Hidden"
|
|
|
|
def test_wlan1_band_is_5ghz(self, driver):
|
|
driver._send_command = self._make_send()
|
|
entries = driver.get_channel_scan()["wlan1"]
|
|
assert all(e["band"] == "5GHz" for e in entries)
|
|
|
|
def test_wlan1_80mhz_width(self, driver):
|
|
driver._send_command = self._make_send()
|
|
entry = driver.get_channel_scan()["wlan1"][0]
|
|
assert entry["channel_width"] == 80
|
|
|
|
def test_passive_mode_uses_passive_flag(self, driver):
|
|
"""With mode='passive', the scan command must contain 'passive'."""
|
|
issued_cmds: list[str] = []
|
|
|
|
def _send(cmd, **kw):
|
|
issued_cmds.append(cmd)
|
|
if cmd.strip().startswith("iw dev") and "scan" not in cmd:
|
|
return IW_DEV_OUT
|
|
return ""
|
|
|
|
driver._send_command = _send
|
|
driver.get_channel_scan(mode="passive")
|
|
scan_cmds = [c for c in issued_cmds if "scan" in c]
|
|
assert all("passive" in c for c in scan_cmds)
|
|
|
|
def test_active_mode_no_passive_flag(self, driver):
|
|
"""With mode='active' (default), scan command must NOT contain 'passive'."""
|
|
issued_cmds: list[str] = []
|
|
|
|
def _send(cmd, **kw):
|
|
issued_cmds.append(cmd)
|
|
if cmd.strip().startswith("iw dev") and "scan" not in cmd:
|
|
return IW_DEV_OUT
|
|
return ""
|
|
|
|
driver._send_command = _send
|
|
driver.get_channel_scan(mode="active")
|
|
scan_cmds = [c for c in issued_cmds if "scan" in c]
|
|
assert all("passive" not in c for c in scan_cmds)
|
|
|
|
def test_no_ap_interfaces_returns_empty(self, driver):
|
|
"""If no AP-mode interfaces exist, return empty dict."""
|
|
no_ap = IW_DEV_OUT.replace("type AP", "type managed")
|
|
driver._send_command = lambda cmd, **kw: (
|
|
no_ap if "iw dev" in cmd and "scan" not in cmd else ""
|
|
)
|
|
result = driver.get_channel_scan()
|
|
assert result == {}
|
|
|
|
def test_empty_iw_output_returns_empty(self, driver):
|
|
driver._send_command = lambda cmd, **kw: ""
|
|
result = driver.get_channel_scan()
|
|
assert result == {}
|
|
|
|
def test_6ghz_band_detected(self, driver):
|
|
iw_dev_6g = """\
|
|
phy#0
|
|
\tInterface wlan0
|
|
\t\taddr aa:bb:cc:dd:ee:f0
|
|
\t\tssid Net6G
|
|
\t\ttype AP
|
|
"""
|
|
scan_6g = """\
|
|
BSS aa:bb:cc:dd:01:ff(on wlan0)
|
|
\tfreq: 5975
|
|
\tsignal: -65.00 dBm
|
|
\tSSID: Neighbor6G
|
|
"""
|
|
|
|
def _send(cmd, **kw):
|
|
if "iw dev" in cmd and "scan" not in cmd:
|
|
return iw_dev_6g
|
|
if "scan" in cmd:
|
|
return scan_6g
|
|
return ""
|
|
|
|
driver._send_command = _send
|
|
entries = driver.get_channel_scan().get("wlan0", [])
|
|
assert len(entries) == 1
|
|
assert entries[0]["band"] == "6GHz"
|
|
assert entries[0]["frequency"] == 5975
|
|
|
|
|
|
class TestPushRadioChannel:
|
|
"""Tests for OpenWrtWirelessMixin.push_radio_channel()."""
|
|
|
|
def test_issues_uci_set(self, driver):
|
|
"""Must call 'uci set wireless.radio0.channel=11'."""
|
|
issued: list[str] = []
|
|
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
|
|
driver.push_radio_channel("radio0", 11)
|
|
assert any("uci set wireless.radio0.channel=11" in c for c in issued)
|
|
|
|
def test_issues_uci_commit(self, driver):
|
|
issued: list[str] = []
|
|
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
|
|
driver.push_radio_channel("radio0", 11)
|
|
assert any("uci commit wireless" in c for c in issued)
|
|
|
|
def test_issues_wifi_restart(self, driver):
|
|
"""Must call 'wifi' (full restart) — NOT just 'wifi reload'."""
|
|
issued: list[str] = []
|
|
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
|
|
driver.push_radio_channel("radio1", 36)
|
|
# 'wifi' alone is a full restart; 'wifi reload' is insufficient for channel changes
|
|
assert any(c.strip() == "wifi" for c in issued)
|
|
|
|
def test_auto_channel_zero(self, driver):
|
|
"""channel=0 should set 'auto' in UCI."""
|
|
issued: list[str] = []
|
|
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
|
|
driver.push_radio_channel("radio0", 0)
|
|
assert any("channel=auto" in c or "channel='auto'" in c for c in issued)
|
|
|
|
def test_commit_before_restart(self, driver):
|
|
"""UCI commit must happen before wifi restart."""
|
|
issued: list[str] = []
|
|
driver._send_command = lambda cmd, **kw: issued.append(cmd) or ""
|
|
driver.push_radio_channel("radio0", 6)
|
|
commit_idx = next(i for i, c in enumerate(issued) if "commit" in c)
|
|
wifi_idx = next(i for i, c in enumerate(issued) if c.strip() == "wifi")
|
|
assert commit_idx < wifi_idx
|
|
|
|
|
|
UCI_WIRELESS_ACL = """\
|
|
wireless.radio0=wifi-device
|
|
wireless.radio0.band='2g'
|
|
wireless.radio1=wifi-device
|
|
wireless.radio1.band='5g'
|
|
wireless.@wifi-iface[0]=wifi-iface
|
|
wireless.@wifi-iface[0].device='radio0'
|
|
wireless.@wifi-iface[0].ssid='CorpWiFi'
|
|
wireless.@wifi-iface[0].encryption='psk2'
|
|
wireless.@wifi-iface[0].macfilter='allow'
|
|
wireless.@wifi-iface[0].maclist='AA:BB:CC:DD:EE:01'
|
|
wireless.@wifi-iface[0].maclist='AA:BB:CC:DD:EE:02'
|
|
wireless.@wifi-iface[1]=wifi-iface
|
|
wireless.@wifi-iface[1].device='radio1'
|
|
wireless.@wifi-iface[1].ssid='CorpWiFi'
|
|
wireless.@wifi-iface[1].encryption='psk2'
|
|
wireless.@wifi-iface[2]=wifi-iface
|
|
wireless.@wifi-iface[2].device='radio0'
|
|
wireless.@wifi-iface[2].ssid='GuestNet'
|
|
wireless.@wifi-iface[2].encryption='none'
|
|
"""
|
|
|
|
|
|
class TestGetSsidsAcl:
|
|
"""Tests for the macfilter/maclist parsing in OpenWrtWirelessMixin.get_ssids()."""
|
|
|
|
def _send(self, cmd, **kw):
|
|
if cmd.strip() == "uci show wireless":
|
|
return UCI_WIRELESS_ACL
|
|
return ""
|
|
|
|
def test_whitelist_mode_parsed(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_ssids()
|
|
assert result["CorpWiFi"]["acl_mode"] == "whitelist"
|
|
|
|
def test_maclist_multiple_entries_parsed(self, driver):
|
|
"""UCI list values repeat the same key across lines — must not overwrite."""
|
|
driver._send_command = self._send
|
|
result = driver.get_ssids()
|
|
assert result["CorpWiFi"]["mac_list"] == ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"]
|
|
|
|
def test_acl_mode_merged_across_radios(self, driver):
|
|
"""Only wifi-iface[0] has macfilter set; wifi-iface[1] (same SSID) must inherit it."""
|
|
driver._send_command = self._send
|
|
result = driver.get_ssids()
|
|
# Both wifi-iface sections belong to CorpWiFi — the merged result carries one acl_mode.
|
|
assert result["CorpWiFi"]["acl_mode"] == "whitelist"
|
|
assert result["CorpWiFi"]["mac_list"] == ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"]
|
|
|
|
def test_no_macfilter_defaults_to_off(self, driver):
|
|
driver._send_command = self._send
|
|
result = driver.get_ssids()
|
|
assert result["GuestNet"]["acl_mode"] == "off"
|
|
assert result["GuestNet"]["mac_list"] == []
|
|
|
|
def test_deny_maps_to_blacklist(self, driver):
|
|
deny_uci = UCI_WIRELESS_ACL.replace("macfilter='allow'", "macfilter='deny'")
|
|
driver._send_command = lambda cmd, **kw: deny_uci if cmd.strip() == "uci show wireless" else ""
|
|
result = driver.get_ssids()
|
|
assert result["CorpWiFi"]["acl_mode"] == "blacklist"
|
|
|
|
|
|
class TestPushMacAcl:
|
|
"""Tests for OpenWrtWirelessMixin.push_mac_acl()."""
|
|
|
|
def _make_send(self, sections="wireless.@wifi-iface[0]\nwireless.@wifi-iface[1]", ssid_by_section=None):
|
|
ssid_by_section = ssid_by_section or {
|
|
"wireless.@wifi-iface[0]": "CorpWiFi",
|
|
"wireless.@wifi-iface[1]": "GuestNet",
|
|
}
|
|
issued: list[str] = []
|
|
|
|
def _send(cmd, **kw):
|
|
issued.append(cmd)
|
|
if "grep -oE" in cmd and "sort -u" in cmd:
|
|
return sections
|
|
for sec, ssid in ssid_by_section.items():
|
|
if f"uci -q get {sec}.ssid" in cmd:
|
|
return ssid
|
|
return ""
|
|
|
|
return _send, issued
|
|
|
|
def test_whitelist_sets_macfilter_allow(self, driver):
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01"])
|
|
assert any("wireless.@wifi-iface[0].macfilter='allow'" in c for c in issued)
|
|
|
|
def test_blacklist_sets_macfilter_deny(self, driver):
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "blacklist", ["AA:BB:CC:DD:EE:01"])
|
|
assert any("wireless.@wifi-iface[0].macfilter='deny'" in c for c in issued)
|
|
|
|
def test_off_deletes_macfilter_option_instead_of_setting_disable(self, driver):
|
|
"""OpenWrt's validator rejects macfilter='disable' outright (confirmed on
|
|
real hardware — it puts netifd in a permanent restart crash loop with
|
|
the radio stuck down). "off" must delete the option, never set it."""
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "off", [])
|
|
assert not any("macfilter='disable'" in c for c in issued)
|
|
assert any("delete wireless.@wifi-iface[0].macfilter" in c for c in issued)
|
|
assert any("delete wireless.@wifi-iface[0].maclist" in c for c in issued)
|
|
assert not any("add_list wireless.@wifi-iface[0].maclist" in c for c in issued)
|
|
|
|
def test_whitelist_with_zero_macs_treated_as_off(self, driver):
|
|
"""An empty whitelist blocks every client outright — must not be pushed
|
|
as macfilter='allow' with an empty list."""
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "whitelist", [])
|
|
assert not any("macfilter='allow'" in c for c in issued)
|
|
assert any("delete wireless.@wifi-iface[0].macfilter" in c for c in issued)
|
|
|
|
def test_blacklist_with_zero_macs_still_pushed(self, driver):
|
|
"""An empty blacklist is safe (blocks nobody) — no guard needed."""
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "blacklist", [])
|
|
assert any("macfilter='deny'" in c for c in issued)
|
|
|
|
def test_maclist_entries_added(self, driver):
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"])
|
|
assert any("add_list wireless.@wifi-iface[0].maclist='AA:BB:CC:DD:EE:01'" in c for c in issued)
|
|
assert any("add_list wireless.@wifi-iface[0].maclist='AA:BB:CC:DD:EE:02'" in c for c in issued)
|
|
|
|
def test_maclist_cleared_before_readd(self, driver):
|
|
"""Full-rebuild: existing maclist must be deleted before new entries are added."""
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01"])
|
|
delete_idx = next(i for i, c in enumerate(issued) if "delete wireless.@wifi-iface[0].maclist" in c)
|
|
add_idx = next(i for i, c in enumerate(issued) if "add_list wireless.@wifi-iface[0].maclist" in c)
|
|
assert delete_idx < add_idx
|
|
|
|
def test_only_matching_ssid_sections_touched(self, driver):
|
|
"""GuestNet section must not be modified when pushing CorpWiFi's ACL."""
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01"])
|
|
assert not any("wireless.@wifi-iface[1].macfilter" in c for c in issued)
|
|
|
|
def test_issues_uci_commit_and_wifi_reload(self, driver):
|
|
send, issued = self._make_send()
|
|
driver._send_command = send
|
|
driver.push_mac_acl("CorpWiFi", "whitelist", ["AA:BB:CC:DD:EE:01"])
|
|
assert any("uci commit wireless" in c for c in issued)
|
|
assert any(c.strip() == "wifi reload" for c in issued)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fix_snmp — firewall zone handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UCI_FIREWALL_HEALTHY = """\
|
|
firewall.@defaults[0]=defaults
|
|
firewall.@defaults[0].input='REJECT'
|
|
firewall.@zone[0]=zone
|
|
firewall.@zone[0].name='lan'
|
|
firewall.@zone[0].network='lan'
|
|
firewall.@zone[0].input='ACCEPT'
|
|
firewall.@zone[1]=zone
|
|
firewall.@zone[1].name='wan'
|
|
firewall.@zone[1].network='wan' 'wan6'
|
|
firewall.@zone[1].input='REJECT'
|
|
firewall.@rule[0]=rule
|
|
firewall.@rule[0].name='Allow-DHCP-Renew'
|
|
firewall.@rule[0].src='wan'
|
|
firewall.@rule[0].dest_port='68'
|
|
"""
|
|
|
|
# The zone that owns the management network lost its 'name' — fw4 skips the
|
|
# whole section and every rule pointing at it.
|
|
UCI_FIREWALL_NAMELESS_ZONE = """\
|
|
firewall.@defaults[0]=defaults
|
|
firewall.@defaults[0].input='REJECT'
|
|
firewall.@zone[0]=zone
|
|
firewall.@zone[0].network='lan'
|
|
firewall.@zone[0].input='ACCEPT'
|
|
firewall.@rule[0]=rule
|
|
firewall.@rule[0].name='Allow-DHCP-Renew'
|
|
firewall.@rule[0].src='wan'
|
|
"""
|
|
|
|
# A dedicated management zone — the AP layout the action is meant to handle.
|
|
UCI_FIREWALL_MGMT_ZONE = """\
|
|
firewall.@zone[0]=zone
|
|
firewall.@zone[0].name='lan'
|
|
firewall.@zone[0].network='lan'
|
|
firewall.@zone[1]=zone
|
|
firewall.@zone[1].name='mgmt'
|
|
firewall.@zone[1].network='mgmt'
|
|
firewall.@zone[1].input='REJECT'
|
|
firewall.@rule[0]=rule
|
|
firewall.@rule[0].name='Allow-SSH'
|
|
firewall.@rule[0].src='mgmt'
|
|
firewall.@rule[0].dest_port='22'
|
|
"""
|
|
|
|
UCI_NETWORK_STATIC = """\
|
|
network.loopback=interface
|
|
network.loopback.device='lo'
|
|
network.lan=interface
|
|
network.lan.device='br-lan'
|
|
network.lan.proto='static'
|
|
network.lan.ipaddr='192.168.1.1'
|
|
network.mgmt=interface
|
|
network.mgmt.device='br-lan.9'
|
|
network.mgmt.proto='static'
|
|
network.mgmt.ipaddr='10.10.0.5'
|
|
"""
|
|
|
|
UCI_NETWORK_DHCP = """\
|
|
network.lan=interface
|
|
network.lan.device='br-lan'
|
|
network.lan.proto='dhcp'
|
|
"""
|
|
|
|
IP_ADDR_BRLAN = """\
|
|
1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever
|
|
7: br-lan inet 10.10.0.5/24 brd 10.10.0.255 scope global br-lan\\ valid_lft forever
|
|
"""
|
|
|
|
|
|
class _FakeShell:
|
|
"""Collects issued commands and answers them from a canned config."""
|
|
|
|
def __init__(self, firewall="", network="", ip_addr="", ssh_connection="",
|
|
reload_out="", nft_hits="1", listen_hits="1", snmpd="running"):
|
|
self.firewall = firewall
|
|
self.network = network
|
|
self.ip_addr = ip_addr
|
|
self.ssh_connection = ssh_connection
|
|
self.reload_out = reload_out
|
|
self.nft_hits = nft_hits
|
|
self.listen_hits = listen_hits
|
|
self.snmpd = snmpd
|
|
self.issued: list[str] = []
|
|
|
|
def __call__(self, cmd, **kw):
|
|
self.issued.append(cmd)
|
|
if cmd.startswith("uci show firewall"):
|
|
return self.firewall
|
|
if cmd.startswith("uci show network"):
|
|
return self.network
|
|
if "$SSH_CONNECTION" in cmd:
|
|
return self.ssh_connection
|
|
if "ip -o -4 addr" in cmd:
|
|
return self.ip_addr
|
|
if "fw4 reload" in cmd or "firewall reload" in cmd:
|
|
return self.reload_out
|
|
if "dport 161" in cmd:
|
|
return self.nft_hits
|
|
if ":161" in cmd:
|
|
return self.listen_hits
|
|
if "snmpd status" in cmd:
|
|
return self.snmpd
|
|
return ""
|
|
|
|
|
|
class TestFixSnmpZoneDetection:
|
|
"""_action_fix_snmp() must resolve the real zone that owns the mgmt address."""
|
|
|
|
def test_static_mgmt_address_selects_owning_zone(self, driver):
|
|
"""10.10.0.5 lives on network 'mgmt' → zone 'mgmt', not the 'lan' fallback."""
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_MGMT_ZONE,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="10.10.0.1 51234 10.10.0.5 22",
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert "Management zone: 'mgmt'" in result["output"]
|
|
assert any("firewall.allow_snmp_from_mgmt.src='mgmt'" in c for c in shell.issued)
|
|
|
|
def test_anonymous_ssh_rule_does_not_decide_the_zone(self, driver):
|
|
"""The old per-line 'src= and ssh' heuristic never matched anonymous rules."""
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_HEALTHY,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert "Management zone: 'lan'" in result["output"]
|
|
|
|
def test_dhcp_mgmt_address_resolved_via_l3_device(self, driver):
|
|
"""No ipaddr in UCI → resolve address → device → network section → zone."""
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_HEALTHY,
|
|
network=UCI_NETWORK_DHCP,
|
|
ip_addr=IP_ADDR_BRLAN,
|
|
ssh_connection="10.10.0.1 51234 10.10.0.5 22",
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert "Management zone: 'lan'" in result["output"]
|
|
|
|
def test_nameless_zone_is_reported_and_action_fails(self, driver):
|
|
"""A zone without 'name' is skipped by fw4 — say so instead of writing a dead rule."""
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_NAMELESS_ZONE,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert result["success"] is False
|
|
assert "@zone[0]" in result["output"]
|
|
assert "name" in result["output"]
|
|
|
|
def test_nameless_zone_does_not_get_a_rule_written(self, driver):
|
|
"""No SNMP rule may be committed while the owning zone is invalid."""
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_NAMELESS_ZONE,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
driver._action_fix_snmp()
|
|
assert not any("allow_snmp" in c for c in shell.issued)
|
|
|
|
|
|
class TestFixSnmpRuleWriting:
|
|
"""The rule must be (re)written idempotently, not skipped when present."""
|
|
|
|
def test_existing_rule_is_repaired_not_skipped(self, driver):
|
|
"""A rule that exists but lacks src must be rewritten, not left broken."""
|
|
broken = UCI_FIREWALL_HEALTHY + (
|
|
"firewall.allow_snmp_from_lan=rule\n"
|
|
"firewall.allow_snmp_from_lan.name='Allow-SNMP-from-lan'\n"
|
|
"firewall.allow_snmp_from_lan.dest_port='161'\n"
|
|
)
|
|
shell = _FakeShell(
|
|
firewall=broken,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
driver._action_fix_snmp()
|
|
assert any("firewall.allow_snmp_from_lan.src='lan'" in c for c in shell.issued)
|
|
|
|
def test_stale_rules_are_deleted_highest_index_first(self, driver):
|
|
"""Anonymous sections shift on delete — descending order keeps the keys valid."""
|
|
stale = UCI_FIREWALL_HEALTHY + (
|
|
"firewall.@rule[1]=rule\n"
|
|
"firewall.@rule[1].name='Allow-SNMP'\n"
|
|
"firewall.@rule[1].dest_port='161'\n"
|
|
"firewall.@rule[2]=rule\n"
|
|
"firewall.@rule[2].name='Allow-SNMP-netOrk'\n"
|
|
"firewall.@rule[2].dest_port='161'\n"
|
|
)
|
|
shell = _FakeShell(
|
|
firewall=stale,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
driver._action_fix_snmp()
|
|
deletes = [c for c in shell.issued if "delete" in c and "@rule" in c]
|
|
joined = " ".join(deletes)
|
|
assert joined.index("@rule[2]") < joined.index("@rule[1]")
|
|
|
|
def test_deletion_is_committed(self, driver):
|
|
"""Old code staged deletes in /tmp/.uci and never committed them."""
|
|
stale = UCI_FIREWALL_HEALTHY + (
|
|
"firewall.snmp_netork=rule\n"
|
|
"firewall.snmp_netork.name='Allow-SNMP-from-mgmt'\n"
|
|
"firewall.snmp_netork.dest_port='161'\n"
|
|
)
|
|
shell = _FakeShell(
|
|
firewall=stale,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
driver._action_fix_snmp()
|
|
delete_idx = next(i for i, c in enumerate(shell.issued) if "snmp_netork" in c and "delete" in c)
|
|
assert any("uci commit firewall" in c for c in shell.issued[delete_idx:])
|
|
|
|
def test_legacy_snmp_netork_rule_is_recognised_as_stale(self, driver):
|
|
"""The pre-0.x rule was named 'Allow-SNMP-from-mgmt' — hyphens, not underscores."""
|
|
stale = UCI_FIREWALL_HEALTHY + (
|
|
"firewall.snmp_netork=rule\n"
|
|
"firewall.snmp_netork.name='Allow-SNMP-from-mgmt'\n"
|
|
"firewall.snmp_netork.src='*'\n"
|
|
"firewall.snmp_netork.dest_port='161'\n"
|
|
)
|
|
shell = _FakeShell(
|
|
firewall=stale,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
driver._action_fix_snmp()
|
|
assert any("snmp_netork" in c and "delete" in c for c in shell.issued)
|
|
|
|
|
|
class TestFixSnmpReloadVerification:
|
|
"""A failing fw4 reload must fail the action, not be swallowed."""
|
|
|
|
FW4_ZONE_ERROR = (
|
|
"Section @zone[0] option 'name' is mandatory but not set\n"
|
|
"Section @zone[0] skipped due to invalid options\n"
|
|
"Section @rule[0] references unknown zone 'lan'\n"
|
|
"Section @rule[0] skipped due to invalid options"
|
|
)
|
|
|
|
def test_reload_error_fails_the_action(self, driver):
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_HEALTHY,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
reload_out=self.FW4_ZONE_ERROR,
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert result["success"] is False
|
|
|
|
def test_reload_output_is_not_truncated(self, driver):
|
|
"""The old 120-char cap hid the 'references unknown zone' line."""
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_HEALTHY,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
reload_out=self.FW4_ZONE_ERROR,
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert "references unknown zone" in result["output"]
|
|
|
|
def test_missing_live_rule_fails_the_action(self, driver):
|
|
"""snmpd up + clean reload, but no udp/161 accept in the packet filter."""
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_HEALTHY,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
nft_hits="0",
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert result["success"] is False
|
|
|
|
def test_snmpd_not_listening_fails_the_action(self, driver):
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_HEALTHY,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
listen_hits="0",
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert result["success"] is False
|
|
|
|
def test_fully_healthy_run_succeeds(self, driver):
|
|
shell = _FakeShell(
|
|
firewall=UCI_FIREWALL_HEALTHY,
|
|
network=UCI_NETWORK_STATIC,
|
|
ssh_connection="192.168.1.50 5000 192.168.1.1 22",
|
|
)
|
|
driver._send_command = shell
|
|
result = driver._action_fix_snmp()
|
|
assert result["success"] is True
|
|
|
|
|
|
class TestUciSectionParser:
|
|
"""_parse_uci_sections() underpins all of the above."""
|
|
|
|
def test_section_type_captured(self, driver):
|
|
parsed = driver._parse_uci_sections(UCI_FIREWALL_HEALTHY)
|
|
assert parsed["@zone[0]"]["_type"] == "zone"
|
|
|
|
def test_option_value_unquoted_on_read(self, driver):
|
|
parsed = driver._parse_uci_sections(UCI_FIREWALL_HEALTHY)
|
|
assert driver._uci_scalar(parsed["@zone[0]"]["name"]) == "lan"
|
|
|
|
def test_list_values_split_into_tokens(self, driver):
|
|
parsed = driver._parse_uci_sections(UCI_FIREWALL_HEALTHY)
|
|
assert driver._uci_tokens(parsed["@zone[1]"]["network"]) == ["wan", "wan6"]
|
|
|
|
def test_value_containing_equals_is_kept_whole(self, driver):
|
|
parsed = driver._parse_uci_sections("firewall.x=rule\nfirewall.x.name='a=b'\n")
|
|
assert driver._uci_scalar(parsed["x"]["name"]) == "a=b"
|
|
|
|
def test_blank_and_malformed_lines_ignored(self, driver):
|
|
parsed = driver._parse_uci_sections("\n\nnot a uci line\nfirewall.x=rule\n")
|
|
assert list(parsed) == ["x"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_system_config — remote syslog, LuCI and bridge STP (netOrk #164)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UCI_SYSTEM_WITH_SYSLOG = """\
|
|
system.@system[0]=system
|
|
system.@system[0].hostname='ap-eze-Garten'
|
|
system.@system[0].timezone='CET-1CEST,M3.5.0,M10.5.0/3'
|
|
system.@system[0].zonename='Europe/Berlin'
|
|
system.@system[0].log_remote='1'
|
|
system.@system[0].log_ip='10.10.40.2'
|
|
system.@system[0].log_port='5514'
|
|
system.@system[0].log_proto='udp'
|
|
system.ntp=timeserver
|
|
system.ntp.server='0.openwrt.pool.ntp.org' '1.openwrt.pool.ntp.org'
|
|
"""
|
|
|
|
UCI_SYSTEM_NO_SYSLOG = """\
|
|
system.@system[0]=system
|
|
system.@system[0].hostname='ap-eze-Parkplatz'
|
|
system.@system[0].timezone='GMT0'
|
|
system.@system[0].zonename='UTC'
|
|
system.ntp=timeserver
|
|
system.ntp.server='0.openwrt.pool.ntp.org'
|
|
"""
|
|
|
|
UCI_DROPBEAR_SYS = """\
|
|
dropbear.@dropbear[0]=dropbear
|
|
dropbear.@dropbear[0].Port='22'
|
|
dropbear.@dropbear[0].PasswordAuth='on'
|
|
dropbear.@dropbear[0].RootPasswordAuth='on'
|
|
"""
|
|
|
|
UCI_NETWORK_STP_ON = """\
|
|
network.ap_bridge=device
|
|
network.ap_bridge.name='br-ap'
|
|
network.ap_bridge.type='bridge'
|
|
network.ap_bridge.stp='1'
|
|
network.lan=interface
|
|
"""
|
|
|
|
UCI_NETWORK_STP_OFF = """\
|
|
network.ap_bridge=device
|
|
network.ap_bridge.name='br-ap'
|
|
network.ap_bridge.type='bridge'
|
|
network.lan=interface
|
|
"""
|
|
|
|
|
|
def _system_send(system_out=UCI_SYSTEM_WITH_SYSLOG, luci_out="1", network_out=UCI_NETWORK_STP_ON):
|
|
"""Dispatch _send_command by the command it receives."""
|
|
|
|
def _send(cmd, **kw):
|
|
if "uci show system" in cmd:
|
|
return system_out
|
|
if "uci show dropbear" in cmd:
|
|
return UCI_DROPBEAR_SYS
|
|
if "uci show network" in cmd:
|
|
return network_out
|
|
if "uhttpd" in cmd:
|
|
return luci_out
|
|
return ""
|
|
|
|
return _send
|
|
|
|
|
|
class TestGetSystemConfigSyslog:
|
|
def test_remote_syslog_target_is_reported(self, driver):
|
|
driver._send_command = _system_send()
|
|
cfg = driver.get_system_config()
|
|
assert cfg["syslog_remote"] is True
|
|
assert cfg["syslog_ip"] == "10.10.40.2"
|
|
assert cfg["syslog_port"] == 5514
|
|
|
|
def test_absent_syslog_reports_as_disabled(self, driver):
|
|
driver._send_command = _system_send(UCI_SYSTEM_NO_SYSLOG)
|
|
cfg = driver.get_system_config()
|
|
assert cfg["syslog_remote"] is False
|
|
assert cfg["syslog_ip"] == ""
|
|
|
|
def test_log_ip_without_log_remote_is_not_active(self, driver):
|
|
# OpenWrt only ships logs when log_remote is set, whatever log_ip says.
|
|
out = UCI_SYSTEM_NO_SYSLOG + "system.@system[0].log_ip='10.10.40.2'\n"
|
|
driver._send_command = _system_send(out)
|
|
assert driver.get_system_config()["syslog_remote"] is False
|
|
|
|
def test_port_falls_back_to_the_openwrt_default(self, driver):
|
|
out = UCI_SYSTEM_NO_SYSLOG + (
|
|
"system.@system[0].log_remote='1'\nsystem.@system[0].log_ip='10.10.40.2'\n"
|
|
)
|
|
driver._send_command = _system_send(out)
|
|
assert driver.get_system_config()["syslog_port"] == 514
|
|
|
|
def test_existing_fields_are_untouched(self, driver):
|
|
driver._send_command = _system_send()
|
|
cfg = driver.get_system_config()
|
|
assert cfg["timezone"] == "CET-1CEST,M3.5.0,M10.5.0/3"
|
|
assert cfg["zonename"] == "Europe/Berlin"
|
|
assert cfg["dropbear_port"] == 22
|
|
assert cfg["ntp_servers"] == ["0.openwrt.pool.ntp.org", "1.openwrt.pool.ntp.org"]
|
|
|
|
|
|
class TestGetSystemConfigLuci:
|
|
def test_enabled_uhttpd_reports_luci_as_reachable(self, driver):
|
|
driver._send_command = _system_send(luci_out="1")
|
|
assert driver.get_system_config()["luci_enabled"] is True
|
|
|
|
def test_disabled_uhttpd_reports_luci_as_unreachable(self, driver):
|
|
driver._send_command = _system_send(luci_out="0")
|
|
assert driver.get_system_config()["luci_enabled"] is False
|
|
|
|
def test_missing_uhttpd_reports_as_unreachable(self, driver):
|
|
# No uhttpd installed at all — LuCI cannot be served.
|
|
driver._send_command = _system_send(luci_out="")
|
|
assert driver.get_system_config()["luci_enabled"] is False
|
|
|
|
|
|
class TestGetSystemConfigBridgeStp:
|
|
def test_stp_enabled_is_reported(self, driver):
|
|
driver._send_command = _system_send(network_out=UCI_NETWORK_STP_ON)
|
|
assert driver.get_system_config()["bridge_stp"] is True
|
|
|
|
def test_absent_stp_option_means_disabled(self, driver):
|
|
# UCI defaults stp to 0 when the option is not present.
|
|
driver._send_command = _system_send(network_out=UCI_NETWORK_STP_OFF)
|
|
assert driver.get_system_config()["bridge_stp"] is False
|
|
|
|
def test_no_ap_bridge_reports_none(self, driver):
|
|
# Nothing to have an opinion about — distinct from "STP is off".
|
|
driver._send_command = _system_send(network_out="network.lan=interface\n")
|
|
assert driver.get_system_config()["bridge_stp"] is None
|