feat: set up SNMP with net-snmp #3

Merged
christianmanivong merged 1 commits from feat/snmp into main 2026-10-08 08:08:46 +00:00
6 changed files with 190 additions and 4 deletions
Showing only changes of commit aef58ca161 - Show all commits
+7
View File
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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`. - Packages: `get_packages`, `install_package`, `uninstall_package`.
- Updates: `get_available_updates`, with VuXML's verdict as `security` on - Updates: `get_available_updates`, with VuXML's verdict as `security` on
FreeBSD and base-system patches as one `base-system` entry (OpenBSD FreeBSD and base-system patches as one `base-system` entry (OpenBSD
+7 -1
View File
@@ -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_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_available_updates` | ✓ | ✓ | `pkg upgrade -n` + `pkg audit` (VuXML); `pkg_add -u -n -v` + `syspatch -c` |
| `get_services`, `manage_service` | ✓ | ✓ | `service -e` + `service … status` / `service … <action>`; `rcctl ls on` + `rcctl check` / `rcctl <action>` | | `get_services`, `manage_service` | ✓ | ✓ | `service -e` + `service … status` / `service … <action>`; `rcctl ls on` + `rcctl check` / `rcctl <action>` |
| `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' `get_listening_sockets` has the shape of napalm-device-types'
`ListeningSocketsMixin` (whose `ss`/cgroup reading is Linux's) and its rule: `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 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. 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 ## Connection arguments
+71
View File
@@ -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_.-]*$") _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_.+-]*$") _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): class BsdDriver(OSDriver):
"""Base for the BSD drivers; a concrete one names the commands that differ.""" """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()))) output = "\n".join(filter(None, (result.stdout.strip(), result.stderr.strip())))
return {"success": result.exit_code == 0, "output": output} 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 ----------------------------------------------- # -- accounts, processes, cron -----------------------------------------------
def get_users(self) -> list[dict[str, Any]]: def get_users(self) -> list[dict[str, Any]]:
+5 -1
View File
@@ -36,7 +36,8 @@ class FreeBSDDriver(BsdDriver):
return parse.sockstat(output) return parse.sockstat(output)
PKG_QUERY = "pkg query '%n\t%v\t%R\t%sb\t%c'" 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}" UNINSTALL_COMMAND = "pkg delete -y {name}"
# Root reads every daemon's pidfile; one-shot scripts have no status. # Root reads every daemon's pidfile; one-shot scripts have no status.
SERVICE_STATUS_COMMAND = ( SERVICE_STATUS_COMMAND = (
@@ -44,6 +45,9 @@ class FreeBSDDriver(BsdDriver):
'printf "%s\\t%s\\n" "$n" "$(service $n status 2>&1 | head -1)"; done' 'printf "%s\\t%s\\n" "$n" "$(service $n status 2>&1 | head -1)"; done'
) )
SERVICE_ACTION_COMMAND = "service {name} {action}" 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]: def _parse_services(self, output: str) -> list[dict]:
return parse.service_status(output) return parse.service_status(output)
+3
View File
@@ -42,6 +42,9 @@ class OpenBSDDriver(BsdDriver):
'printf "%s\\t%s\\n" "$s" "$r"; done' 'printf "%s\\t%s\\n" "$s" "$r"; done'
) )
SERVICE_ACTION_COMMAND = "rcctl {action} {name}" 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]: def _parse_services(self, output: str) -> list[dict]:
return parse.rcctl_check(output) return parse.rcctl_check(output)
+97 -2
View File
@@ -274,7 +274,11 @@ class TestPackages:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("cls", "install", "uninstall"), ("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"), (OpenBSDDriver, "pkg_add -I nginx", "pkg_delete nginx"),
], ],
) )
@@ -287,7 +291,7 @@ class TestPackages:
def test_a_version_is_part_of_the_name(self): def test_a_version_is_part_of_the_name(self):
driver = _channel(FreeBSDDriver, {}) driver = _channel(FreeBSDDriver, {})
driver.install_package("nginx", "1.28.0") 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): def test_a_failed_install_raises(self):
driver = _channel(OpenBSDDriver, {("pkg_add -I nope", True): ("Can't find nope", 1)}) driver = _channel(OpenBSDDriver, {("pkg_add -I nope", True): ("Can't find nope", 1)})
@@ -437,3 +441,94 @@ class TestServices:
with pytest.raises(ValueError): with pytest.raises(ValueError):
driver.manage_service(name, action) driver.manage_service(name, action)
assert driver.calls == [] 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