From d45c082355b69a231ab8c32fe09a34a369df3c3c Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Sat, 29 Aug 2026 22:41:36 +0700 Subject: [PATCH] feat(system): report remote syslog, LuCI state and bridge STP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- napalm_openwrt/system_mixin.py | 39 ++++++++++ tests/unit/test_driver.py | 133 +++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/napalm_openwrt/system_mixin.py b/napalm_openwrt/system_mixin.py index 99d8ecf..2a569e0 100644 --- a/napalm_openwrt/system_mixin.py +++ b/napalm_openwrt/system_mixin.py @@ -75,9 +75,17 @@ class OpenWrtSystemMixin: * dropbear_port (int) — SSH port * dropbear_password_auth (bool) — whether password login is allowed * dropbear_root_password_auth (bool) + * syslog_remote (bool) — whether logs are shipped off the device + * syslog_ip (str), syslog_port (int), syslog_proto (str) + * luci_enabled (bool) — whether the uhttpd service serving LuCI is enabled + * bridge_stp (bool | None) — STP on br-ap; None when there is no br-ap """ sys_out = self._send_command("uci show system 2>/dev/null || true") db_out = self._send_command("uci show dropbear 2>/dev/null || true") + luci_out = self._send_command( + "/etc/init.d/uhttpd enabled 2>/dev/null && echo 1 || echo 0" + ) + net_out = self._send_command("uci show network 2>/dev/null || true") sys_cfg: dict[str, str] = {} for line in sys_out.splitlines(): @@ -109,6 +117,31 @@ class OpenWrtSystemMixin: def _bool_uci(val: str, default: bool = True) -> bool: return val.lower() not in ("0", "off", "false", "no") if val else default + # Remote syslog. OpenWrt only ships logs when log_remote is set, so a + # leftover log_ip without it means nothing is being sent. + syslog_remote = _bool_uci(sys_cfg.get("log_remote", ""), default=False) + try: + syslog_port = int(sys_cfg.get("log_port", "514") or "514") + except (ValueError, TypeError): + syslog_port = 514 + + # STP on the AP bridge. None when there is no br-ap device section at + # all — "no bridge" is a different statement from "bridge without STP". + bridge_stp: bool | None = None + br_section: str | None = None + for line in net_out.splitlines(): + m = re.match(r"network\.(\w+)\.name='br-ap'", line.strip()) + if m: + br_section = m.group(1) + break + if br_section: + bridge_stp = False + for line in net_out.splitlines(): + m = re.match(rf"network\.{br_section}\.stp='([^']*)'", line.strip()) + if m: + bridge_stp = _bool_uci(m.group(1), default=False) + break + return { "hostname": sys_cfg.get("hostname", ""), "timezone": sys_cfg.get("timezone", ""), @@ -117,6 +150,12 @@ class OpenWrtSystemMixin: "dropbear_port": ssh_port, "dropbear_password_auth": _bool_uci(db_cfg.get("PasswordAuth", "on")), "dropbear_root_password_auth": _bool_uci(db_cfg.get("RootPasswordAuth", "on")), + "syslog_remote": syslog_remote, + "syslog_ip": sys_cfg.get("log_ip", "") if syslog_remote else "", + "syslog_port": syslog_port, + "syslog_proto": sys_cfg.get("log_proto", "udp"), + "luci_enabled": luci_out.strip().endswith("1"), + "bridge_stp": bridge_stp, } def get_snmp_information(self) -> dict[str, Any]: diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index b10eb24..7b24938 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -1734,3 +1734,136 @@ class TestUciSectionParser: 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