feat!: configure_snmp_agent with the caller's values replaces fix_snmp (0.3.0)
fix_snmp wrote an application's snmpd.conf (community public, Managed by netOrk, netork@localhost) and judged itself by a local snmpget. The drivers implement napalm-device-types 4.13's SnmpAgentMixin steps instead: net-snmp installed, snmpd.conf with one rocommunity line per allowed source (staged without privilege, installed by root), snmpd enabled and restarted. pf and ipfw rules stay the operator's. A community or text that would break the file is refused. The caller verifies with its own SNMP client. Also get_management_peer() from SSH_CONNECTION, ensure_package from the base package, and get_snmp_config no longer assumes "public". BREAKING CHANGE: run_device_action is removed. Requires napalm-device-types 4.13. Closes #12
This commit is contained in:
1 parent
6ff556d714
commit
d06da7cc8b
4 files changed
+125
-60
No files matched your search
+61
-40
@@ -8,6 +8,7 @@ stdin, as napalm-linux does it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
@@ -16,7 +17,8 @@ from typing import Any, Optional
|
||||
|
||||
import paramiko
|
||||
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
||||
from napalm_device_types import OSDriver
|
||||
from napalm_device_types import EnsurePackageMixin, OSDriver, SnmpAgentMixin
|
||||
from napalm_device_types.models import FixStepOutcomeDict, SnmpAgentSettingsDict
|
||||
from napalm_device_types.host_status import HostStatusMixin
|
||||
from napalm_device_types.channel import (
|
||||
ByteStream,
|
||||
@@ -37,13 +39,10 @@ _SERVICE_ACTIONS = frozenset({"start", "stop", "restart", "enable", "disable"})
|
||||
_SERVICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
||||
_PACKAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]*$")
|
||||
|
||||
#: The agent netOrk sets up, as on Linux: v2c, community "public", every address.
|
||||
_SNMPD_CONF = (
|
||||
"agentAddress udp:161\n"
|
||||
"rocommunity public\n"
|
||||
"sysLocation Managed by netOrk\n"
|
||||
"sysContact netork@localhost\n"
|
||||
)
|
||||
#: What may stand in an snmpd.conf value: no whitespace, no quote, no comment.
|
||||
_SNMP_WORD = re.compile(r'^[^\s"#]+$')
|
||||
#: The SSH client side of this connection: the address the host sees it come from.
|
||||
_PEER_COMMAND = 'printf "%s\\n" "$SSH_CONNECTION"'
|
||||
#: Why ``run_command`` refuses stdin to a privileged command while a sudo
|
||||
#: password is set (#8).
|
||||
_PRIVILEGED_STDIN = (
|
||||
@@ -63,11 +62,30 @@ _STAGE = (
|
||||
'{ cat > "$t" && printf %s "$t" || { s=$?; rm -f -- "$t"; exit $s; }; }'
|
||||
)
|
||||
|
||||
#: What a working agent answers sysDescr.0 with.
|
||||
_SNMP_TYPES = ("STRING:", "INTEGER:", "OID:", "Timeticks:", "Hex-STRING:", "IpAddress:")
|
||||
def _source(value: str) -> str:
|
||||
"""An allowed source as an address or network, or ValueError."""
|
||||
return str(ipaddress.ip_network(value.strip(), strict=False))
|
||||
|
||||
|
||||
class BsdDriver(HostStatusMixin, OSDriver):
|
||||
def render_snmpd_conf(settings: SnmpAgentSettingsDict) -> str:
|
||||
"""net-snmp's configuration for *settings*: read-only v2c, per allowed source."""
|
||||
community = settings["community"]
|
||||
if not _SNMP_WORD.match(community):
|
||||
raise ValueError("the community must be one word without quotes or #")
|
||||
listen = settings.get("listen")
|
||||
lines = [f"agentAddress udp:{listen + ':' if listen else ''}161"]
|
||||
sources = [_source(s) for s in settings.get("allowed_sources", [])]
|
||||
lines += [f"rocommunity {community} {s}" for s in sources] or [f"rocommunity {community}"]
|
||||
for key, directive in (("location", "sysLocation"), ("contact", "sysContact")):
|
||||
value = settings.get(key, "")
|
||||
if value:
|
||||
if "\n" in value:
|
||||
raise ValueError(f"{key} must be one line")
|
||||
lines.append(f"{directive} {value}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
"""Base for the BSD drivers; a concrete one names the commands that differ."""
|
||||
|
||||
VENDOR = ""
|
||||
@@ -451,43 +469,46 @@ class BsdDriver(HostStatusMixin, OSDriver):
|
||||
SNMPD_START = ""
|
||||
#: net-snmp's daemon; OpenBSD's base snmpd is /usr/sbin/snmpd.
|
||||
SNMPD_DAEMON = "/usr/local/sbin/snmpd"
|
||||
SNMP_PROBE = "snmpget -v2c -cpublic -t2 -r0 -Ov 127.0.0.1 1.3.6.1.2.1.1.1.0"
|
||||
|
||||
def run_device_action(self, action: str) -> dict[str, Any]:
|
||||
"""Execute a named action on the device; ``fix_snmp`` is the one there is."""
|
||||
if action == "fix_snmp":
|
||||
return self._action_fix_snmp()
|
||||
raise NotImplementedError(f"Unknown action: {action!r}")
|
||||
def get_management_peer(self) -> Optional[str]:
|
||||
"""The address this host sees the SSH connection come from (``SSH_CONNECTION``)."""
|
||||
out = self.run_command(_PEER_COMMAND, timeout=15).stdout.split()
|
||||
try:
|
||||
return str(ipaddress.ip_address(out[0])) if out else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _action_fix_snmp(self) -> dict[str, Any]:
|
||||
"""Install net-snmp, configure it as on Linux, start it, and ask it.
|
||||
# The steps of napalm-device-types' configure_snmp_agent: net-snmp rather than
|
||||
# the base daemons (FreeBSD bsnmpd, OpenBSD snmpd), as it answers UCD-SNMP-MIB.
|
||||
|
||||
net-snmp rather than the base daemons (FreeBSD bsnmpd, OpenBSD snmpd):
|
||||
it answers UCD-SNMP-MIB, which netOrk's health metrics read
|
||||
(NetOrk/netork#800). Stops at the first step that fails.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
steps = (
|
||||
("install", lambda: self.run_command(
|
||||
self.INSTALL_COMMAND.format(name="net-snmp"), privileged=True, timeout=600)),
|
||||
("config", lambda: self._install_file_as_root(self.SNMPD_CONF, _SNMPD_CONF)),
|
||||
("service", lambda: self.run_command(self.SNMPD_START, privileged=True, timeout=600)),
|
||||
)
|
||||
for label, step in steps:
|
||||
result = step()
|
||||
output = "\n".join(filter(None, (result.stdout.strip(), result.stderr.strip())))
|
||||
lines.append(f"[{label}] {output[-300:]}".rstrip())
|
||||
if result.exit_code != 0:
|
||||
return {"success": False, "output": "\n".join(lines)}
|
||||
probe = self.run_command(self.SNMP_PROBE, timeout=30).stdout.strip()
|
||||
lines.append(f"[probe] {probe}")
|
||||
return {"success": any(t in probe for t in _SNMP_TYPES), "output": "\n".join(lines)}
|
||||
def _step(self, command: str, done: str) -> FixStepOutcomeDict:
|
||||
result = self.run_command(command, privileged=True, timeout=600)
|
||||
output = "\n".join(filter(None, (result.stdout.strip(), result.stderr.strip())))
|
||||
return {"ok": result.exit_code == 0, "output": done if result.exit_code == 0 else output[-300:]}
|
||||
|
||||
def _install_snmp_agent(self) -> FixStepOutcomeDict:
|
||||
return self._step(self.INSTALL_COMMAND.format(name="net-snmp"), "net-snmp installed")
|
||||
|
||||
def _write_snmp_agent_config(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict:
|
||||
written = self._install_file_as_root(self.SNMPD_CONF, render_snmpd_conf(settings))
|
||||
if written.exit_code:
|
||||
return {"ok": False, "output": (written.stdout or written.stderr).strip()[-300:]}
|
||||
return {"ok": True, "output": f"wrote {self.SNMPD_CONF}"}
|
||||
|
||||
def _allow_snmp_sources(self, settings: SnmpAgentSettingsDict) -> FixStepOutcomeDict:
|
||||
# pf and ipfw rule sets are the operator's; the agent itself answers only
|
||||
# the allowed sources (one rocommunity line each).
|
||||
return {"ok": True, "output": "pf/ipfw unchanged; snmpd answers only the allowed sources"}
|
||||
|
||||
def _start_snmp_agent(self) -> FixStepOutcomeDict:
|
||||
return self._step(self.SNMPD_START, "snmpd enabled and running")
|
||||
|
||||
def get_snmp_config(self) -> Optional[dict[str, Any]]:
|
||||
"""net-snmp's community and port, if its daemon runs; None otherwise."""
|
||||
if not self._out(f"pgrep -f {self.SNMPD_DAEMON}"):
|
||||
return None
|
||||
community, port = "public", 161
|
||||
# A community that is not in the file is not known; no default is assumed.
|
||||
community, port = "", 161
|
||||
for line in self._out(f"cat {self.SNMPD_CONF}").splitlines():
|
||||
words = line.split()
|
||||
if len(words) >= 2 and words[0].lower() in (
|
||||
|
||||
Reference in new issue
Block a user