diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a9c1c..b00a86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- SNMP: `run_device_action("fix_snmp")` installs net-snmp, writes the same + configuration netOrk uses on Linux, starts the agent and asks it for + sysDescr; `get_snmp_config` reports a running agent's community and port. + +### Changed +- FreeBSD's `install_package` bootstraps pkg on a classic system that never + had it (`ASSUME_ALWAYS_YES`) instead of waiting for an answer. - Packages: `get_packages`, `install_package`, `uninstall_package`. - Updates: `get_available_updates`, with VuXML's verdict as `security` on FreeBSD and base-system patches as one `base-system` entry (OpenBSD diff --git a/README.md b/README.md index f948037..c1b89a8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ over SSH, built on [napalm-device-types](https://git.netork.io/NAPALM/napalm-dev | `get_packages`, `install_package`, `uninstall_package` | ✓ | ✓ | `pkg query` / `pkg install`, `pkg delete`; `pkg_info` / `pkg_add -I`, `pkg_delete` | | `get_available_updates` | ✓ | ✓ | `pkg upgrade -n` + `pkg audit` (VuXML); `pkg_add -u -n -v` + `syspatch -c` | | `get_services`, `manage_service` | ✓ | ✓ | `service -e` + `service … status` / `service … `; `rcctl ls on` + `rcctl check` / `rcctl ` | +| `run_device_action("fix_snmp")`, `get_snmp_config` | ✓ | ✓ | net-snmp from packages: `/usr/local/etc/snmp/snmpd.conf` + `service snmpd`; `/etc/snmp/snmpd.conf` + `rcctl … netsnmpd` | `get_listening_sockets` has the shape of napalm-device-types' `ListeningSocketsMixin` (whose `ss`/cgroup reading is Linux's) and its rule: @@ -61,7 +62,12 @@ needs no extra entry. root-only pidfiles hide a daemon from anyone else, and without root when sudo refuses; OpenBSD's `rcctl check` needs no root. Actions run as root. -SNMP (#800) follows. +**SNMP** is net-snmp from packages (NetOrk/netork#800), configured as netOrk does +on Linux (v2c, community `public`, every address), so the agent answers +UCD-SNMP-MIB for netOrk's health metrics. Memory, swap and load are right on +both systems. CPU: FreeBSD reports `ssCpuIdle` about a minute after the +agent starts; on OpenBSD net-snmp's CPU figures are wrong (0 % idle on an idle +machine, also in `hrProcessorLoad`). ## Connection arguments diff --git a/napalm_bsd/base.py b/napalm_bsd/base.py index 0ce5f58..53662e4 100644 --- a/napalm_bsd/base.py +++ b/napalm_bsd/base.py @@ -36,6 +36,16 @@ _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 a working agent answers sysDescr.0 with. +_SNMP_TYPES = ("STRING:", "INTEGER:", "OID:", "Timeticks:", "Hex-STRING:", "IpAddress:") + class BsdDriver(OSDriver): """Base for the BSD drivers; a concrete one names the commands that differ.""" @@ -344,6 +354,67 @@ class BsdDriver(OSDriver): output = "\n".join(filter(None, (result.stdout.strip(), result.stderr.strip()))) return {"success": result.exit_code == 0, "output": output} + # -- SNMP (net-snmp from packages, NetOrk/netork#800) ------------------------- + + #: Where net-snmp reads its configuration, and how its daemon is started. + SNMPD_CONF = "" + 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 _action_fix_snmp(self) -> dict[str, Any]: + """Install net-snmp, configure it as on Linux, start it, and ask it. + + 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] = [] + conf_dir = self.SNMPD_CONF.rsplit("/", 1)[0] + steps = ( + ("install", self.INSTALL_COMMAND.format(name="net-snmp"), None), + ( + "config", + f"mkdir -p {conf_dir} && cat > {self.SNMPD_CONF} && chmod 644 {self.SNMPD_CONF}", + _SNMPD_CONF.encode(), + ), + ("service", self.SNMPD_START, None), + ) + for label, command, stdin in steps: + result = self.run_command(command, privileged=True, timeout=600, stdin=stdin) + 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 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 + for line in self._out(f"cat {self.SNMPD_CONF}").splitlines(): + words = line.split() + if len(words) >= 2 and words[0].lower() in ( + "rocommunity", + "rwcommunity", + "rocommunity6", + ): + community = words[1] + elif words and words[0] == "agentAddress": + found = re.search(r":(\d+)", line) + port = int(found[1]) if found else port + return {"running": True, "community": community, "port": port, "version": "2c"} + # -- accounts, processes, cron ----------------------------------------------- def get_users(self) -> list[dict[str, Any]]: diff --git a/napalm_bsd/freebsd.py b/napalm_bsd/freebsd.py index 4906043..a98d8cc 100644 --- a/napalm_bsd/freebsd.py +++ b/napalm_bsd/freebsd.py @@ -36,7 +36,8 @@ class FreeBSDDriver(BsdDriver): return parse.sockstat(output) PKG_QUERY = "pkg query '%n\t%v\t%R\t%sb\t%c'" - INSTALL_COMMAND = "pkg install -y {name}" + # Bootstraps pkg on a classic system that never had it, instead of asking. + INSTALL_COMMAND = "env ASSUME_ALWAYS_YES=yes pkg install -y {name}" UNINSTALL_COMMAND = "pkg delete -y {name}" # Root reads every daemon's pidfile; one-shot scripts have no status. SERVICE_STATUS_COMMAND = ( @@ -44,6 +45,9 @@ class FreeBSDDriver(BsdDriver): 'printf "%s\\t%s\\n" "$n" "$(service $n status 2>&1 | head -1)"; done' ) SERVICE_ACTION_COMMAND = "service {name} {action}" + SNMPD_CONF = "/usr/local/etc/snmp/snmpd.conf" + # The rc script drops to the snmpd user, so the file stays readable (644). + SNMPD_START = "sysrc snmpd_enable=YES && service snmpd restart" def _parse_services(self, output: str) -> list[dict]: return parse.service_status(output) diff --git a/napalm_bsd/openbsd.py b/napalm_bsd/openbsd.py index 66130eb..703f702 100644 --- a/napalm_bsd/openbsd.py +++ b/napalm_bsd/openbsd.py @@ -42,6 +42,9 @@ class OpenBSDDriver(BsdDriver): 'printf "%s\\t%s\\n" "$s" "$r"; done' ) SERVICE_ACTION_COMMAND = "rcctl {action} {name}" + SNMPD_CONF = "/etc/snmp/snmpd.conf" + # net-snmp's rc script; "snmpd" is OpenBSD's own daemon. + SNMPD_START = "rcctl enable netsnmpd && rcctl restart netsnmpd" def _parse_services(self, output: str) -> list[dict]: return parse.rcctl_check(output) diff --git a/tests/test_drivers.py b/tests/test_drivers.py index 9a2640e..e207cc2 100644 --- a/tests/test_drivers.py +++ b/tests/test_drivers.py @@ -274,7 +274,11 @@ class TestPackages: @pytest.mark.parametrize( ("cls", "install", "uninstall"), [ - (FreeBSDDriver, "pkg install -y nginx", "pkg delete -y nginx"), + ( + FreeBSDDriver, + "env ASSUME_ALWAYS_YES=yes pkg install -y nginx", + "pkg delete -y nginx", + ), (OpenBSDDriver, "pkg_add -I nginx", "pkg_delete nginx"), ], ) @@ -287,7 +291,7 @@ class TestPackages: def test_a_version_is_part_of_the_name(self): driver = _channel(FreeBSDDriver, {}) driver.install_package("nginx", "1.28.0") - assert driver.calls == [("pkg install -y nginx-1.28.0", True)] + assert driver.calls == [("env ASSUME_ALWAYS_YES=yes pkg install -y nginx-1.28.0", True)] def test_a_failed_install_raises(self): driver = _channel(OpenBSDDriver, {("pkg_add -I nope", True): ("Can't find nope", 1)}) @@ -437,3 +441,94 @@ class TestServices: with pytest.raises(ValueError): driver.manage_service(name, action) assert driver.calls == [] + + +class TestSnmp: + """net-snmp from packages, as decided in NetOrk/netork#800: UCD-SNMP-MIB, + the same health metrics as on Linux.""" + + PROBE_OK = "STRING: FreeBSD host 15.1-RELEASE-p4 FreeBSD 15.1-RELEASE-p4 GENERIC amd64" + + @pytest.mark.parametrize( + ("cls", "install", "conf", "start"), + [ + ( + FreeBSDDriver, + "env ASSUME_ALWAYS_YES=yes pkg install -y net-snmp", + "/usr/local/etc/snmp/snmpd.conf", + "sysrc snmpd_enable=YES && service snmpd restart", + ), + ( + OpenBSDDriver, + "pkg_add -I net-snmp", + "/etc/snmp/snmpd.conf", + "rcctl enable netsnmpd && rcctl restart netsnmpd", + ), + ], + ) + def test_fix_snmp_installs_configures_and_starts_net_snmp(self, cls, install, conf, start): + driver = _channel(cls, {BsdDriver.SNMP_PROBE: self.PROBE_OK}) + stdin_seen = {} + run = driver.run_command + + def recording(command, *, privileged=False, timeout=60, stdin=None): + if stdin is not None: + stdin_seen[command] = stdin + return run(command, privileged=privileged, timeout=timeout, stdin=stdin) + + driver.run_command = recording # type: ignore[method-assign] + result = driver.run_device_action("fix_snmp") + + assert result["success"] is True + commands = [c for c, _ in driver.calls] + assert commands[0] == install + assert any(conf in c and "cat >" in c for c in commands) + assert start in commands + assert commands[-1] == BsdDriver.SNMP_PROBE + assert all(p for c, p in driver.calls if c != BsdDriver.SNMP_PROBE) # root for the setup + written = next(v for k, v in stdin_seen.items() if conf in k).decode() + assert "agentAddress udp:161" in written and "rocommunity public" in written + + def test_a_failed_install_stops_and_says_so(self): + driver = _channel( + FreeBSDDriver, + { + ("env ASSUME_ALWAYS_YES=yes pkg install -y net-snmp", True): ( + "pkg: No packages available", + 1, + ) + }, + ) + result = driver.run_device_action("fix_snmp") + assert result["success"] is False + assert "No packages available" in result["output"] + assert len(driver.calls) == 1 + + def test_no_answer_from_the_agent_is_a_failure(self): + driver = _channel( + OpenBSDDriver, {BsdDriver.SNMP_PROBE: "Timeout: No Response from 127.0.0.1"} + ) + assert driver.run_device_action("fix_snmp")["success"] is False + + def test_unknown_action(self): + with pytest.raises(NotImplementedError): + _channel(FreeBSDDriver, {}).run_device_action("fix_apt_proxy") + + def test_snmp_config_of_a_running_agent(self): + driver = _channel( + FreeBSDDriver, + { + "pgrep -f /usr/local/sbin/snmpd": "1234", + "cat /usr/local/etc/snmp/snmpd.conf": "agentAddress udp:1161\nrocommunity s3cret\n", + }, + ) + assert driver.get_snmp_config() == { + "running": True, + "community": "s3cret", + "port": 1161, + "version": "2c", + } + + def test_no_agent_running_means_no_config(self): + driver = _channel(OpenBSDDriver, {"pgrep -f /usr/local/sbin/snmpd": ("", 1)}) + assert driver.get_snmp_config() is None