feat!: fix templates with the caller's values replace run_device_action (0.2.0)
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.10) (pull_request) Successful in 25s
CI / test (3.11) (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 25s
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.10) (pull_request) Successful in 25s
CI / test (3.11) (pull_request) Successful in 23s
CI / test (3.12) (pull_request) Successful in 25s
fix_snmp set no community at all and opened the management zone's udp/161 to every source, with a rule named after an application; a second fix_snmp in the system mixin was shadowed and never ran (#5). install_lldpd, install_auc and install_coreutils_base64 were actions of their own. The driver implements napalm-device-types 4.13's templates instead: - configure_snmp_agent(settings): snmpd installed when missing; community, contact and location in UCI with one com2sec (com2sec6) per allowed source; the zone-resolving firewall step of the old fix, now limited to the sources (src_ip) and named Allow-SNMP; snmpd enabled, restarted and listening; - get_management_peer() from SSH_CONNECTION; - ensure_package(name) from the base package, over install_package. A community, contact or location that would break UCI is refused. BREAKING CHANGE: run_device_action is removed. Requires napalm-device-types 4.13. Closes #5. Part of #13.
This commit is contained in:
1 parent
d569a289ba
commit
968d6583ba
5 files changed
+244
-300
No files matched your search
+122
-132
@@ -19,6 +19,7 @@ Netmiko device_type: ``linux``
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
from typing import Any
|
||||
@@ -26,7 +27,12 @@ from typing import Any
|
||||
from netmiko import ConnectHandler
|
||||
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException
|
||||
|
||||
from napalm_device_types import AccessPointDriver, FingerprintRule
|
||||
from napalm_device_types import (
|
||||
AccessPointDriver,
|
||||
EnsurePackageMixin,
|
||||
FingerprintRule,
|
||||
SnmpAgentMixin,
|
||||
)
|
||||
from napalm_device_types.listening import ListeningSocketsMixin
|
||||
from napalm.base.exceptions import (
|
||||
ConnectionException,
|
||||
@@ -39,11 +45,23 @@ from napalm_openwrt.wireless_mixin import OpenWrtWirelessMixin
|
||||
from napalm_openwrt.lldp_mixin import OpenWrtLLDPMixin
|
||||
from napalm_openwrt.vlan_mixin import OpenWrtVLANMixin
|
||||
from napalm_openwrt.config_mixin import OpenWrtConfigMixin
|
||||
from napalm_openwrt.system_mixin import OpenWrtSystemMixin
|
||||
from napalm_openwrt.system_mixin import _RC_MARKER, OpenWrtSystemMixin, _split_status
|
||||
from napalm_openwrt.packages_mixin import OpenWrtPackageMixin
|
||||
from napalm_openwrt.routing_mixin import OpenWrtRoutingMixin
|
||||
|
||||
|
||||
#: What may stand in a UCI value written in single quotes: no whitespace, quote or #.
|
||||
_SNMP_WORD = re.compile(r"^[^\s'\"#]+$")
|
||||
|
||||
|
||||
def _sources(settings: dict) -> list[str]:
|
||||
"""The allowed sources as networks, or ValueError."""
|
||||
return [
|
||||
str(ipaddress.ip_network(x.strip(), strict=False))
|
||||
for x in settings.get("allowed_sources", [])
|
||||
]
|
||||
|
||||
|
||||
class OpenWrtDriver(
|
||||
OpenWrtInterfaceMixin,
|
||||
OpenWrtVLANMixin,
|
||||
@@ -54,6 +72,8 @@ class OpenWrtDriver(
|
||||
OpenWrtPackageMixin,
|
||||
OpenWrtRoutingMixin,
|
||||
ListeningSocketsMixin,
|
||||
SnmpAgentMixin,
|
||||
EnsurePackageMixin,
|
||||
AccessPointDriver,
|
||||
):
|
||||
"""NAPALM driver for OpenWrt routers and access-points."""
|
||||
@@ -358,18 +378,6 @@ class OpenWrtDriver(
|
||||
# Device actions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run_device_action(self, action: str) -> dict[str, Any]:
|
||||
"""Execute a named action on the device."""
|
||||
if action == "install_lldpd":
|
||||
return self._action_install_lldpd()
|
||||
if action == "install_auc":
|
||||
return self._action_install_auc()
|
||||
if action == "install_coreutils_base64":
|
||||
return self._action_install_coreutils_base64()
|
||||
if action == "fix_snmp":
|
||||
return self._action_fix_snmp()
|
||||
raise NotImplementedError(f"Unknown action: {action!r}")
|
||||
|
||||
def _mgmt_address(self) -> str:
|
||||
"""Return the device-side address of our own SSH session.
|
||||
|
||||
@@ -487,43 +495,83 @@ class OpenWrtDriver(
|
||||
return int(line)
|
||||
return 0
|
||||
|
||||
def _action_fix_snmp(self) -> dict[str, Any]:
|
||||
"""Ensure snmpd is running and reachable on UDP/161.
|
||||
# ------------------------------------------------------------------
|
||||
# SNMP agent (napalm-device-types SnmpAgentMixin): the caller's values
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
On OpenWrt the usual reason SNMP stays unreachable is the firewall:
|
||||
snmpd listens, but the zone covering the management interface
|
||||
default-drops everything except SSH/HTTP/HTTPS/ICMP. A rule only
|
||||
lands in the right nftables chain when its ``src`` names the zone
|
||||
that actually owns the management network.
|
||||
def get_management_peer(self) -> str | None:
|
||||
"""The address the device sees the SSH connection come from (``SSH_CONNECTION``)."""
|
||||
parts = self._send_command("echo $SSH_CONNECTION").split()
|
||||
try:
|
||||
return str(ipaddress.ip_address(parts[0])) if parts else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
This action:
|
||||
def _rc_step(self, command: str, done: str) -> dict[str, Any]:
|
||||
output, status = _split_status(self._send_command(f"{command} 2>&1; echo {_RC_MARKER}$?"))
|
||||
return {"ok": status == 0, "output": done if status == 0 else output.strip()[-300:]}
|
||||
|
||||
1. Resolves the management address from ``$SSH_CONNECTION``, maps it
|
||||
to its ``network`` section and from there to the owning firewall
|
||||
zone — no guessing from rule names.
|
||||
2. Aborts with a diagnosis when that zone has no ``option name``:
|
||||
fw4 skips such a section, so any rule written against it is dead
|
||||
on arrival. Naming the zone is left to the operator because an
|
||||
inert zone becoming active changes what the AP filters.
|
||||
3. Removes stale SNMP rules from earlier versions (highest anonymous
|
||||
index first) and commits the deletion.
|
||||
4. Writes the ``allow_snmp_from_<zone>`` rule in full every run, so a
|
||||
half-written rule from a previous attempt gets repaired.
|
||||
5. Reloads the firewall and fails on any reload complaint.
|
||||
6. Verifies on the device that snmpd listens and that a udp/161
|
||||
accept rule is live in the packet filter.
|
||||
def _install_snmp_agent(self) -> dict[str, Any]:
|
||||
if self._send_command("[ -x /usr/sbin/snmpd ] && echo present").strip() == "present":
|
||||
return {"ok": True, "output": "snmpd already installed"}
|
||||
if self._pm_type() == "apk":
|
||||
return self._rc_step("apk add snmpd", "snmpd installed")
|
||||
return self._rc_step("opkg update >/dev/null 2>&1; opkg install snmpd", "snmpd installed")
|
||||
|
||||
def _write_snmp_agent_config(self, settings: dict) -> dict[str, Any]:
|
||||
"""Community, sources, contact and location in UCI's snmpd config.
|
||||
|
||||
One ``com2sec`` (``com2sec6``) per allowed source, all mapped to the
|
||||
read-only security name the stock groups use. Sections of an earlier run
|
||||
(``snmp_src*``) go first. Without sources every address may ask; with only
|
||||
IPv4 sources, IPv6 answers localhost only.
|
||||
"""
|
||||
community = settings["community"]
|
||||
if not _SNMP_WORD.match(community):
|
||||
raise ValueError("the community must be one word without quotes or #")
|
||||
sources = _sources(settings)
|
||||
v4 = [x for x in sources if ":" not in x] or (["127.0.0.1/32"] if sources else ["default"])
|
||||
v6 = [x for x in sources if ":" in x] or (["::1/128"] if sources else ["default"])
|
||||
old = self._parse_uci_sections(self._send_command("uci show snmpd 2>/dev/null"))
|
||||
stale = sorted((key for key in old if key.startswith("snmp_src")), reverse=True)
|
||||
cmds = [f"uci -q delete snmpd.{key}" for key in stale]
|
||||
for kind, first, nets in (("com2sec", "public", v4), ("com2sec6", "public6", v6)):
|
||||
for i, net in enumerate(nets):
|
||||
key = first if i == 0 else f"snmp_src{'6' if kind.endswith('6') else ''}_{i}"
|
||||
cmds += [
|
||||
f"uci set snmpd.{key}={kind}",
|
||||
f"uci set snmpd.{key}.secname='ro'",
|
||||
f"uci set snmpd.{key}.source='{net}'",
|
||||
f"uci set snmpd.{key}.community='{community}'",
|
||||
]
|
||||
for key, option in (("location", "sysLocation"), ("contact", "sysContact")):
|
||||
value = settings.get(key, "")
|
||||
if value:
|
||||
if "'" in value or "\n" in value:
|
||||
raise ValueError(f"{key} must be one line without '")
|
||||
cmds.append(f"uci set snmpd.@system[0].{option}='{value}'")
|
||||
cmds.append("uci commit snmpd")
|
||||
done = f"community set for {', '.join(sources) or 'every address'}"
|
||||
return self._rc_step("; ".join(cmds), done)
|
||||
|
||||
def _allow_snmp_sources(self, settings: dict) -> dict[str, Any]:
|
||||
"""A udp/161 accept rule in the zone that owns the management address.
|
||||
|
||||
The zone is resolved from ``$SSH_CONNECTION`` → its ``network`` section →
|
||||
the zone listing it, not guessed from rule names. A zone without ``option
|
||||
name`` is skipped by fw4, so the step fails with how to fix it instead of
|
||||
writing a dead rule. Stale SNMP rules go (highest anonymous index first);
|
||||
the rule is written in full every run and limited to the allowed sources
|
||||
(``src_ip``); the reload and the live packet filter decide success.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# ── 1. Resolve the zone that owns the management address ─────────
|
||||
fw_raw = self._send_command("uci show firewall 2>/dev/null")
|
||||
fw_sections = self._parse_uci_sections(fw_raw)
|
||||
sources = _sources(settings)
|
||||
fw_sections = self._parse_uci_sections(self._send_command("uci show firewall 2>/dev/null"))
|
||||
zones, nameless = self._firewall_zones(fw_sections)
|
||||
|
||||
address = self._mgmt_address()
|
||||
net_sections = self._parse_uci_sections(self._send_command("uci show network 2>/dev/null"))
|
||||
mgmt_net = self._mgmt_network(net_sections, address)
|
||||
lines.append(f"[firewall] Management address {address} on network {mgmt_net or '<unknown>'}")
|
||||
lines.append(f"Management address {address} on network {mgmt_net or '<unknown>'}")
|
||||
|
||||
broken_zone = (
|
||||
self._zone_for_network(fw_sections, {key: key for key in nameless}, mgmt_net)
|
||||
@@ -532,50 +580,45 @@ class OpenWrtDriver(
|
||||
)
|
||||
if broken_zone:
|
||||
lines.append(
|
||||
f"[firewall] Zone {broken_zone} owns network {mgmt_net!r} but has no "
|
||||
f"'name' option — fw4 skips the section and drops every rule that "
|
||||
f"references it. Fix on the device, then re-run:"
|
||||
f"Zone {broken_zone} owns network {mgmt_net!r} but has no 'name' option — "
|
||||
"fw4 skips the section and drops every rule that references it. "
|
||||
"Fix on the device, then re-run:"
|
||||
)
|
||||
lines.append(
|
||||
f"[firewall] uci set firewall.{broken_zone}.name='{mgmt_net}' "
|
||||
f"&& uci commit firewall && fw4 reload"
|
||||
f" uci set firewall.{broken_zone}.name='{mgmt_net}'"
|
||||
" && uci commit firewall && fw4 reload"
|
||||
)
|
||||
return {"success": False, "output": "\n".join(lines)}
|
||||
return {"ok": False, "output": "\n".join(lines)}
|
||||
|
||||
mgmt_zone = self._zone_for_network(fw_sections, zones, mgmt_net) if mgmt_net else ""
|
||||
if not mgmt_zone and "lan" in zones:
|
||||
mgmt_zone = "lan"
|
||||
if not mgmt_zone and len(zones) == 1:
|
||||
mgmt_zone = next(iter(zones))
|
||||
|
||||
if mgmt_zone:
|
||||
lines.append(f"[firewall] Management zone: {mgmt_zone!r}")
|
||||
lines.append(f"Management zone: {mgmt_zone!r}")
|
||||
elif zones:
|
||||
lines.append(
|
||||
f"[firewall] No zone covers the management network — zones present: "
|
||||
f"{', '.join(sorted(zones))}"
|
||||
)
|
||||
return {"success": False, "output": "\n".join(lines)}
|
||||
present = ", ".join(sorted(zones))
|
||||
lines.append(f"No zone covers the management network — zones present: {present}")
|
||||
return {"ok": False, "output": "\n".join(lines)}
|
||||
else:
|
||||
lines.append("[firewall] No zones configured — writing an unscoped rule")
|
||||
lines.append("No zones configured — writing an unscoped rule")
|
||||
|
||||
# ── 2. Drop stale SNMP rules from earlier versions of this action ─
|
||||
named_key = f"allow_snmp_from_{mgmt_zone}" if mgmt_zone else "allow_snmp"
|
||||
stale = self._stale_snmp_rules(fw_sections, keep=named_key)
|
||||
if stale:
|
||||
self._send_command(
|
||||
"; ".join(f"uci -q delete firewall.{key}" for key in stale)
|
||||
+ "; uci commit firewall"
|
||||
)
|
||||
lines.append(f"[firewall] Removed stale SNMP rule(s): {', '.join(stale)}")
|
||||
deletes = "; ".join(f"uci -q delete firewall.{key}" for key in stale)
|
||||
self._send_command(f"{deletes}; uci commit firewall")
|
||||
lines.append(f"Removed stale SNMP rule(s): {', '.join(stale)}")
|
||||
|
||||
# ── 3. Write the rule in full — repairs a half-written one ────────
|
||||
rule_cmds = [
|
||||
f"uci set firewall.{named_key}=rule",
|
||||
f"uci set firewall.{named_key}.name='Allow-SNMP-netOrk'",
|
||||
f"uci set firewall.{named_key}.name='Allow-SNMP'",
|
||||
f"uci set firewall.{named_key}.target='ACCEPT'",
|
||||
f"uci set firewall.{named_key}.proto='udp'",
|
||||
f"uci set firewall.{named_key}.dest_port='161'",
|
||||
f"uci -q delete firewall.{named_key}.src_ip",
|
||||
*(f"uci add_list firewall.{named_key}.src_ip='{net}'" for net in sources),
|
||||
]
|
||||
if mgmt_zone:
|
||||
rule_cmds.append(f"uci set firewall.{named_key}.src='{mgmt_zone}'")
|
||||
@@ -583,86 +626,33 @@ class OpenWrtDriver(
|
||||
rule_cmds.append(f"uci -q delete firewall.{named_key}.src")
|
||||
rule_cmds.append("uci commit firewall")
|
||||
rule_out = self._send_command("; ".join(rule_cmds) + " 2>&1").strip()
|
||||
lines.append(f"[firewall] Wrote rule {named_key!r}: {rule_out or 'ok'}")
|
||||
scope = ", ".join(sources) or "every source in the zone"
|
||||
lines.append(f"Wrote rule {named_key!r} for {scope}: {rule_out or 'ok'}")
|
||||
|
||||
# ── 4. Reload the firewall, and believe what it says ──────────────
|
||||
reload_out = self._send_command(
|
||||
"fw4 reload 2>&1 || /etc/init.d/firewall reload 2>&1"
|
||||
).strip()
|
||||
fw_ok = not self._fw_reload_failed(reload_out)
|
||||
lines.append(f"[firewall] Reload: {reload_out or 'ok'}")
|
||||
lines.append(f"Reload: {reload_out or 'ok'}")
|
||||
if not fw_ok:
|
||||
lines.append("[firewall] Reload reported invalid sections — ruleset not applied")
|
||||
|
||||
# ── 5. Ensure snmpd is enabled and running ────────────────────────
|
||||
status = self._send_command("/etc/init.d/snmpd status 2>/dev/null")
|
||||
if "running" not in status.lower() and "active" not in status.lower():
|
||||
self._send_command(
|
||||
"/etc/init.d/snmpd enable 2>/dev/null;"
|
||||
" /etc/init.d/snmpd start 2>/dev/null"
|
||||
)
|
||||
lines.append("[snmpd] Service started and enabled")
|
||||
else:
|
||||
lines.append("[snmpd] Service already running")
|
||||
|
||||
# ── 6. Verify on the device instead of assuming success ───────────
|
||||
listening = self._grep_count(
|
||||
self._send_command("ss -lun 2>/dev/null | grep -c ':161'")
|
||||
)
|
||||
lines.append(f"[probe] snmpd listening on udp/161: {'yes' if listening else 'no'}")
|
||||
|
||||
lines.append("Reload reported invalid sections — ruleset not applied")
|
||||
live = self._grep_count(
|
||||
self._send_command(
|
||||
"{ nft list ruleset 2>/dev/null || iptables-save 2>/dev/null; }"
|
||||
" | grep -c 'dport 161'"
|
||||
)
|
||||
)
|
||||
lines.append(f"[probe] udp/161 accept rules live in the packet filter: {live}")
|
||||
lines.append(f"udp/161 accept rules live in the packet filter: {live}")
|
||||
return {"ok": fw_ok and bool(live), "output": "\n".join(lines)}
|
||||
|
||||
success = fw_ok and bool(listening) and bool(live)
|
||||
return {"success": success, "output": "\n".join(lines)}
|
||||
|
||||
def _action_install_coreutils_base64(self) -> dict[str, Any]:
|
||||
"""Install coreutils-base64 via the device package manager."""
|
||||
pm = self._pm_type()
|
||||
if pm == "apk":
|
||||
raw = self._send_command("apk add coreutils-base64 2>&1")
|
||||
else:
|
||||
self._send_command("opkg update 2>&1")
|
||||
raw = self._send_command("opkg install coreutils-base64 2>&1")
|
||||
out = self._clean_pkg_output(raw)
|
||||
low = out.lower()
|
||||
success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package"))
|
||||
return {"success": success, "output": out}
|
||||
|
||||
def _action_install_auc(self) -> dict[str, Any]:
|
||||
"""Install the attended sysupgrade client (auc) via opkg."""
|
||||
self._send_command("opkg update 2>&1")
|
||||
raw = self._send_command("opkg install auc 2>&1")
|
||||
out = self._clean_pkg_output(raw)
|
||||
low = out.lower()
|
||||
success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package"))
|
||||
return {"success": success, "output": out}
|
||||
|
||||
def _action_install_lldpd(self) -> dict[str, Any]:
|
||||
"""Install lldpd, add eth0 to its interface list and start the service."""
|
||||
pm = self._pm_type()
|
||||
if pm == "apk":
|
||||
raw = self._send_command("apk add lldpd 2>&1")
|
||||
else:
|
||||
self._send_command("opkg update 2>&1")
|
||||
raw = self._send_command("opkg install lldpd 2>&1")
|
||||
out = self._clean_pkg_output(raw)
|
||||
|
||||
# Configure the correct management interface and enable the service
|
||||
self._lldpd_fix_interface()
|
||||
|
||||
# Enable and start the service
|
||||
def _start_snmp_agent(self) -> dict[str, Any]:
|
||||
self._send_command(
|
||||
"/etc/init.d/lldpd enable 2>/dev/null; "
|
||||
"/etc/init.d/lldpd start 2>/dev/null"
|
||||
"/etc/init.d/snmpd enable 2>/dev/null; /etc/init.d/snmpd restart 2>/dev/null"
|
||||
)
|
||||
|
||||
low = out.lower()
|
||||
success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package"))
|
||||
return {"success": success, "output": out}
|
||||
listening = self._grep_count(self._send_command("ss -lun 2>/dev/null | grep -c ':161'"))
|
||||
return {
|
||||
"ok": bool(listening),
|
||||
"output": "snmpd enabled and listening on udp/161"
|
||||
if listening
|
||||
else "snmpd is not listening on udp/161",
|
||||
}
|
||||
Reference in new issue
Block a user