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

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:
Christian Manivong committed 2026-10-17 08:33:25 +02:00
1 parent d569a289ba
commit 968d6583ba
5 files changed
+244 -300

No files matched your search

-151
View File
@@ -14,7 +14,6 @@
from __future__ import annotations
import re
import time as _time
from typing import Any
@@ -258,156 +257,6 @@ class OpenWrtSystemMixin:
return SNMPConfigDict(running=True, community=community, port=161, version="2c")
def _action_fix_snmp(self) -> dict[str, Any]:
"""Install and configure snmpd on OpenWrt.
Installs snmpd-nossl (the daemon) and luci-app-snmpd (UCI schema +
proper procd init script). Configures community 'public' via UCI.
"""
lines: list = []
# 1. Install packages — snmpd-nossl (daemon) + luci-app-snmpd (UCI init)
pm = self._pm_type()
if pm == "apk":
raw = self._send_command("apk add snmpd-nossl luci-app-snmpd 2>&1")
else:
self._send_command("opkg update 2>/dev/null || true")
raw = self._send_command("opkg install snmpd-nossl luci-app-snmpd 2>&1")
out = self._clean_pkg_output(raw)
low = out.lower()
installed = not any(kw in low for kw in ("error:", "failed"))
lines.append(f"[install] {out[-300:]}")
if not installed and "already installed" not in low:
return {"success": False, "output": "\n".join(lines)}
# 2. Configure via UCI — modify the existing default sections only.
# Do NOT create new named sections (causes duplicate directives in
# the generated /var/run/snmpd.conf which crashes snmpd).
# Also remove any stale named sections from previous fix attempts.
# The init script reads these UCI field names to generate /var/run/snmpd.conf:
# agent: agentaddress
# com2sec: secname, source, community
# group: group (name!), version, secname
# view: viewname (not name!), type, oid
# access: group, version, level, prefix, read, write, notify
# Default luci-app-snmpd schema uses different field names for group/view/access,
# so we patch all required fields explicitly.
uci_cmds = [
# Remove any stale named sections from previous runs
"uci -q delete snmpd.agent",
"uci -q delete snmpd.public",
# agent
"uci set snmpd.@agent[0].agentaddress='161'",
# com2sec
"uci set snmpd.@com2sec[0].secname='ro'",
"uci set snmpd.@com2sec[0].source='0.0.0.0/0'",
"uci set snmpd.@com2sec[0].community='public'",
# group — init script reads field 'group' (not 'name')
"uci set snmpd.@group[0].group='rogroup'",
"uci set snmpd.@group[0].version='v2c'",
"uci set snmpd.@group[0].secname='ro'",
# view — init script reads field 'viewname' (not 'name')
"uci set snmpd.@view[0].viewname='all'",
"uci set snmpd.@view[0].type='included'",
"uci set snmpd.@view[0].oid='.1'",
# access — init script needs write + notify or it returns early
"uci set snmpd.@access[0].group='rogroup'",
"uci set snmpd.@access[0].context='none'",
"uci set snmpd.@access[0].version='v2c'",
"uci set snmpd.@access[0].level='noAuthNoPriv'",
"uci set snmpd.@access[0].prefix='exact'",
"uci set snmpd.@access[0].read='all'",
"uci set snmpd.@access[0].write='none'",
"uci set snmpd.@access[0].notify='none'",
"uci commit snmpd",
]
for cmd in uci_cmds:
self._send_command(f"{cmd} 2>/dev/null || true")
lines.append("[config] Configured snmpd via UCI (all required fields set).")
# 3. Firewall: allow UDP 161 from the management zone
try:
# Use `ss` (always available on OpenWrt) to find the IP of the current SSH client
raw_conn = self._send_command(
"ss -tnp 2>/dev/null | awk '/ESTAB.*:22/{print $5}' | head -1 | sed 's/:[0-9]*$//'"
).strip()
# Strip IPv6-mapped prefix if present
if raw_conn.startswith("::ffff:"):
raw_conn = raw_conn[7:]
peer_ip = raw_conn.splitlines()[-1].strip() if raw_conn else ""
if peer_ip and peer_ip not in ("", "0.0.0.0", "::"):
# Determine which firewall zone owns the interface that routes to peer_ip.
# Walk uci firewall zones: find the zone whose associated network interface
# has an address in the same /24 as peer_ip.
peer_prefix = peer_ip.rsplit(".", 1)[0] if "." in peer_ip else ""
fw_out = self._send_command("uci show firewall 2>/dev/null || true")
src_zone = "*"
# Build map zone_name → zone_uci_key
zone_map: dict[str, str] = {}
for fw_line in fw_out.splitlines():
m = re.match(r"firewall\.(\w+)\.name='([^']+)'", fw_line.strip())
if m:
zone_map[m.group(2)] = m.group(1)
# For each zone find its network, then the interface IP
for zone_name, uci_key in zone_map.items():
net_line = self._send_command(
f"uci -q get firewall.{uci_key}.network 2>/dev/null || true"
).strip()
if not net_line:
continue
for net_name in net_line.split():
iface_ip = self._send_command(
f"uci -q get network.{net_name}.ipaddr 2>/dev/null || true"
).strip()
if iface_ip and "." in iface_ip:
iface_prefix = iface_ip.split("/")[0].rsplit(".", 1)[0]
if peer_prefix and iface_prefix == peer_prefix:
src_zone = zone_name
break
if src_zone != "*":
break
self._send_command(
f"uci -q delete firewall.snmp_netork 2>/dev/null; "
f"uci set firewall.snmp_netork=rule; "
f"uci set firewall.snmp_netork.name='Allow-SNMP-from-mgmt'; "
f"uci set firewall.snmp_netork.src='{src_zone}'; "
f"uci set firewall.snmp_netork.dest_port='161'; "
f"uci set firewall.snmp_netork.proto='udp'; "
f"uci set firewall.snmp_netork.target='ACCEPT'; "
f"uci commit firewall; "
f"/etc/init.d/firewall reload 2>/dev/null || true"
)
lines.append(f"[firewall] Added UDP:161 allow rule (src zone: {src_zone}).")
else:
lines.append("[firewall] Could not detect peer IP via ss — skipping firewall step.")
except Exception as exc:
lines.append(f"[firewall] skipped — {exc}")
# 4. Break any crash-loop, then start cleanly
self._send_command("/etc/init.d/snmpd stop 2>/dev/null; true")
_time.sleep(2)
self._send_command("pkill -9 snmpd 2>/dev/null; true") # kill crash-loop zombie
_time.sleep(3)
self._send_command("/etc/init.d/snmpd enable 2>/dev/null; true")
self._send_command("/etc/init.d/snmpd start 2>/dev/null; true")
_time.sleep(4)
lines.append("[service] snmpd started via procd.")
# 5. Check if snmpd is now active (no local snmpget on OpenWrt by default)
status = self._send_command(
"/etc/init.d/snmpd running 2>/dev/null && echo active || echo inactive"
).strip()
success = status == "active"
if success:
lines.append("[ok] snmpd is active.")
else:
lines.append(f"[warn] snmpd status: {status}")
return {"success": success, "output": "\n".join(lines)}
def get_users(self) -> dict[str, Any]:
"""Return users configured on the device.