Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca6827421 | ||
|
|
040a073863 | ||
|
|
121343ee51 | ||
|
|
78576c225c | ||
|
|
de00041b46 | ||
|
|
4a95a5f0d4 | ||
|
|
6179607137 | ||
|
|
68dd818da6 | ||
|
|
ee0200462b | ||
|
|
6ff556d714 | ||
|
|
707e048792 | ||
|
|
992bc6d5e9 | ||
|
|
1921a3f044 | ||
|
|
2a92ec77e3 | ||
|
|
32a63411d3 | ||
|
|
ae09e44345 | ||
|
|
25308bf747 | ||
|
|
1172b4d9d5 | ||
|
|
aef58ca161 | ||
|
|
7bf028ef4d |
No files matched your search
@@ -0,0 +1,15 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
driver:
|
||||||
|
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
|
||||||
|
with:
|
||||||
|
standard-ref: main
|
||||||
|
secrets:
|
||||||
|
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["**"]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
python-version: ["3.10", "3.11", "3.12"]
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install package with dev extras
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
# napalm-device-types lives in git.netork.io/NAPALM, not on PyPI: without this
|
|
||||||
# pip looks there, finds an unrelated 0.1.0 and the job dies before any test.
|
|
||||||
python -m pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git"
|
|
||||||
python -m pip install -e ".[dev]"
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: |
|
|
||||||
python -m pytest -q --tb=short
|
|
||||||
|
|
||||||
- name: Build wheel and sdist
|
|
||||||
run: |
|
|
||||||
python -m pip install build
|
|
||||||
python -m build
|
|
||||||
|
|
||||||
- name: Upload dist artifacts
|
|
||||||
# v4 refuses to run on Gitea ("not currently supported on GHES").
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: dist-${{ matrix.python-version }}
|
|
||||||
path: dist/*
|
|
||||||
+69
-9
@@ -7,16 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-10-23
|
||||||
|
|
||||||
|
The first release on PyPI.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The drivers follow the NAPALM driver standard
|
||||||
|
(https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the
|
||||||
|
tests on Python 3.10–3.13 and at the lowest declared versions, bandit, pip-audit and
|
||||||
|
a build check, and a release tag publishes to PyPI. The package now ships `py.typed`.
|
||||||
|
Python 3.10 stays the floor. napalm-device-types now comes from PyPI, 4.21.1 or later.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- The SSH host key is verified (NetOrk/netork#1167). `open()` takes the key to expect from `optional_args["ssh_host_key"]` and refuses any other one before it logs in (`SshHostKeyMismatch`); without one it accepts the first key. Every key it sees goes to `optional_args["ssh_host_key_observer"]`. Replaces paramiko's `AutoAddPolicy`. Requires napalm-device-types 4.19.0; version 0.4.0.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-10-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Breaking:** `run_device_action("fix_snmp")` is gone. The drivers implement
|
||||||
|
napalm-device-types 4.13's `configure_snmp_agent(settings)` template instead, and
|
||||||
|
the caller supplies every value: one `rocommunity` line per allowed source, contact
|
||||||
|
and location as given. No community, contact or location of an application is built
|
||||||
|
in any more. pf and ipfw rules are left to the operator. Requires
|
||||||
|
napalm-device-types 4.13.0 or later.
|
||||||
|
- `get_snmp_config` no longer assumes the community `public` when the file names none.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `get_management_peer()`: the address the host sees the SSH connection come from.
|
||||||
|
- `ensure_package(name)` from the base package.
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-10-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A sudo password no longer ends up in `snmpd.conf`. `run_command` sent the
|
||||||
|
password and the data on the same stdin, and a sudo that does not prompt
|
||||||
|
(NOPASSWD, a cached timestamp) left the password for `cat`, which wrote it
|
||||||
|
as the first line of a file then made world-readable (#8). `run_command` now
|
||||||
|
refuses stdin to a privileged command while a sudo password is set, and
|
||||||
|
`fix_snmp` stages the configuration without privilege before root installs
|
||||||
|
it, without following a symlink at the target.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-10-13
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `get_users` is now `get_os_users`, as napalm-device-types 4.7 names it on
|
||||||
|
`OSDriver`; the return shape is unchanged. NAPALM's own `get_users` has
|
||||||
|
another shape and a stub on every driver, so a poll asking `hasattr` could
|
||||||
|
not tell the two apart. Requires napalm-device-types 4.7.1 or later.
|
||||||
|
|
||||||
|
## [0.1.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- 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
|
|
||||||
`syspatch`, classic FreeBSD `freebsd-update`).
|
|
||||||
- Services: `get_services` and `manage_service` (start, stop, restart,
|
|
||||||
enable, disable) through `service` and `rcctl`.
|
|
||||||
- `get_listening_sockets()` in the shape of `ListeningSocketsMixin`: FreeBSD
|
|
||||||
through `sockstat`, OpenBSD through `fstat` as root and `netstat -an`
|
|
||||||
without, which the reading reports as `attributed: False`.
|
|
||||||
- `FreeBSDDriver` (`freebsd`) and `OpenBSDDriver` (`openbsd`) on a shared
|
- `FreeBSDDriver` (`freebsd`) and `OpenBSDDriver` (`openbsd`) on a shared
|
||||||
`BsdDriver`: SSH over exec channels (no PTY, real exit codes), root through
|
`BsdDriver`: SSH over exec channels (no PTY, real exit codes), root through
|
||||||
`sudo -S` with the password on stdin.
|
`sudo -S` with the password on stdin.
|
||||||
@@ -25,5 +66,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
their addresses (`ifconfig -a`), routes (`netstat -rn`), ARP (`arp -an`),
|
their addresses (`ifconfig -a`), routes (`netstat -rn`), ARP (`arp -an`),
|
||||||
users and groups, processes (`ps … lstart`), cron jobs (system crontab and
|
users and groups, processes (`ps … lstart`), cron jobs (system crontab and
|
||||||
the login user's).
|
the login user's).
|
||||||
|
- `get_listening_sockets()` in the shape of `ListeningSocketsMixin`: FreeBSD
|
||||||
|
through `sockstat`, OpenBSD through `fstat` as root and `netstat -an`
|
||||||
|
without, which the reading reports as `attributed: False`.
|
||||||
|
- Packages: `get_packages`, `install_package`, `uninstall_package`. FreeBSD's
|
||||||
|
install bootstraps pkg on a classic system that never had it.
|
||||||
|
- Updates: `get_available_updates`, with VuXML's verdict as `security` on
|
||||||
|
FreeBSD and base-system patches as one `base-system` entry (OpenBSD
|
||||||
|
`syspatch`, classic FreeBSD `freebsd-update`). A reader that cannot read
|
||||||
|
raises instead of returning no updates (#4).
|
||||||
|
- Services: `get_services` and `manage_service` (start, stop, restart,
|
||||||
|
enable, disable) through `service` and `rcctl`.
|
||||||
|
- 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.
|
||||||
|
- `get_host_status` (napalm-device-types' `HostStatusMixin`): on FreeBSD,
|
||||||
|
"reboot required" when the installed kernel differs from the running one.
|
||||||
|
- `first_boot_pending()`: whether a FreeBSD host is still in its first boot
|
||||||
|
(`/firstboot`), so netOrk can wait before setting up a new VM
|
||||||
|
(NetOrk/netork#795).
|
||||||
- Parsers tested on output recorded from FreeBSD 15.1 (hand-installed and
|
- Parsers tested on output recorded from FreeBSD 15.1 (hand-installed and
|
||||||
cloud image) and OpenBSD 7.9 (NetOrk/netork#793).
|
cloud image) and OpenBSD 7.9 (NetOrk/netork#793).
|
||||||
@@ -31,11 +31,14 @@ over SSH, built on [napalm-device-types](https://git.netork.io/NAPALM/napalm-dev
|
|||||||
| `get_route_to` | ✓ | ✓ | `netstat -rnW` / `netstat -rn` |
|
| `get_route_to` | ✓ | ✓ | `netstat -rnW` / `netstat -rn` |
|
||||||
| `get_arp_table` | ✓ | ✓ | `arp -an` |
|
| `get_arp_table` | ✓ | ✓ | `arp -an` |
|
||||||
| `get_lldp_neighbors` | `{}` | `{}` | no LLDP daemon in either base system |
|
| `get_lldp_neighbors` | `{}` | `{}` | no LLDP daemon in either base system |
|
||||||
| `get_users`, `get_processes`, `get_cron_jobs` | ✓ | ✓ | `/etc/passwd`+`/etc/group`, `ps … lstart`, system crontab + `crontab -l` |
|
| `get_os_users`, `get_processes`, `get_cron_jobs` | ✓ | ✓ | `/etc/passwd`+`/etc/group`, `ps … lstart`, system crontab + `crontab -l` |
|
||||||
| `get_listening_sockets` | ✓ | ✓ | `sockstat -46lq` / `fstat -n` as root, `netstat -an` without |
|
| `get_listening_sockets` | ✓ | ✓ | `sockstat -46lq` / `fstat -n` as root, `netstat -an` without |
|
||||||
| `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_host_status` | ✓ | unknown | napalm-device-types' host status: on FreeBSD `freebsd-version -k` vs `-r` (4.1+) |
|
||||||
|
| `first_boot_pending` | ✓ | always `False` | whether `/firstboot` still exists |
|
||||||
|
|
||||||
`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:
|
||||||
@@ -57,11 +60,30 @@ reports what `freebsd-update` has fetched (`updatesready`). On FreeBSD with
|
|||||||
pkgbase the base system is packages from the `FreeBSD-base` repository and
|
pkgbase the base system is packages from the `FreeBSD-base` repository and
|
||||||
needs no extra entry.
|
needs no extra entry.
|
||||||
|
|
||||||
|
A reader that cannot read **raises** rather than return an empty list: netOrk
|
||||||
|
takes `[]` as "no updates" and would close every patch clock on the host. That
|
||||||
|
covers a refused sudo, a failing `pkg`, `syspatch` or `freebsd-update`, and a pkg
|
||||||
|
database pkg cannot read (only pkg's "not installed" means no packages).
|
||||||
|
|
||||||
|
**Reboot.** `get_host_status` reports a FreeBSD host whose installed kernel
|
||||||
|
(`freebsd-version -k`) differs from the running one (`-r`) as needing a reboot.
|
||||||
|
OpenBSD has no such reading yet, so it stays unknown there.
|
||||||
|
|
||||||
|
**First boot.** A FreeBSD cloud image upgrades its base system on its first boot,
|
||||||
|
starts sshd only after that and restarts right away. `/etc/rc` removes `/firstboot`
|
||||||
|
just before the restart, so `first_boot_pending()` is true until then; netOrk waits
|
||||||
|
for it before it sets up a new VM. OpenBSD has no such marker to ask yet.
|
||||||
|
|
||||||
**Services** are the enabled ones. FreeBSD reads their status as root, as
|
**Services** are the enabled ones. FreeBSD reads their status as root, as
|
||||||
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,7 +93,6 @@ SNMP (#800) follows.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git"
|
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
+210
-20
@@ -8,21 +8,24 @@ stdin, as napalm-linux does it.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
from shlex import quote
|
from shlex import quote
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import paramiko
|
import paramiko
|
||||||
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
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.channel import (
|
from napalm_device_types.channel import (
|
||||||
ByteStream,
|
ByteStream,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
open_stream_on_transport,
|
open_stream_on_transport,
|
||||||
run_on_transport,
|
run_on_transport,
|
||||||
)
|
)
|
||||||
|
from napalm_device_types.host_status import HostStatusMixin
|
||||||
|
from napalm_device_types.models import FixStepOutcomeDict, SnmpAgentSettingsDict
|
||||||
|
from napalm_device_types.ssh_host_keys import HostKeyCheck, SshHostKeyMismatch
|
||||||
|
|
||||||
from napalm_bsd import parse
|
from napalm_bsd import parse
|
||||||
|
|
||||||
@@ -36,8 +39,55 @@ _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_.+-]*$")
|
||||||
|
|
||||||
|
#: 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 = (
|
||||||
|
"run_command(privileged=True) takes no stdin while a sudo password is set: "
|
||||||
|
"sudo -S reads the password from stdin, and a sudo that does not prompt "
|
||||||
|
"(NOPASSWD, a cached timestamp) leaves it for the command, which would read it "
|
||||||
|
"as the first line of the data. Stage the data without privilege and have root "
|
||||||
|
"install that file."
|
||||||
|
)
|
||||||
|
|
||||||
class BsdDriver(OSDriver):
|
#: Stages content without privilege: a fresh ``mktemp`` file only the login user
|
||||||
|
#: may read, never a fixed name another user could create first. Prints its path.
|
||||||
|
#: The path is on the device, not a local temp file.
|
||||||
|
_STAGE_TEMPLATE = "/tmp/napalm-bsd-stage.XXXXXX" # nosec B108
|
||||||
|
_STAGE_PREFIX = _STAGE_TEMPLATE[: -len("XXXXXX")]
|
||||||
|
_STAGE = (
|
||||||
|
f"umask 077 && t=$(mktemp {_STAGE_TEMPLATE}) && "
|
||||||
|
'{ cat > "$t" && printf %s "$t" || { s=$?; rm -f -- "$t"; exit $s; }; }'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source(value: str) -> str:
|
||||||
|
"""An allowed source as an address or network, or ValueError."""
|
||||||
|
return str(ipaddress.ip_network(value.strip(), strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
"""Base for the BSD drivers; a concrete one names the commands that differ."""
|
||||||
|
|
||||||
VENDOR = ""
|
VENDOR = ""
|
||||||
@@ -64,7 +114,7 @@ class BsdDriver(OSDriver):
|
|||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
timeout: int = 60,
|
timeout: int = 60,
|
||||||
optional_args: Optional[dict] = None,
|
optional_args: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.username = username
|
self.username = username
|
||||||
@@ -72,22 +122,22 @@ class BsdDriver(OSDriver):
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
args = optional_args or {}
|
args = optional_args or {}
|
||||||
self.port: int = int(args.get("port", 22))
|
self.port: int = int(args.get("port", 22))
|
||||||
self._key_file: Optional[str] = args.get("key_file")
|
self._key_file: str | None = args.get("key_file")
|
||||||
self._sudo_password: Optional[str] = args.get("sudo_password")
|
self._sudo_password: str | None = args.get("sudo_password")
|
||||||
self._allow_agent: bool = bool(args.get("allow_agent", False))
|
self._allow_agent: bool = bool(args.get("allow_agent", False))
|
||||||
self._look_for_keys: bool = bool(args.get("look_for_keys", False))
|
self._look_for_keys: bool = bool(args.get("look_for_keys", False))
|
||||||
self._client: Optional[paramiko.SSHClient] = None
|
# The host key to expect, and where the one seen is reported (netork#1167).
|
||||||
self._root: Optional[bool] = None
|
self._host_key = HostKeyCheck(args, hostname, self.port)
|
||||||
|
self._client: paramiko.SSHClient | None = None
|
||||||
|
self._root: bool | None = None
|
||||||
|
|
||||||
# -- connection --------------------------------------------------------------
|
# -- connection --------------------------------------------------------------
|
||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
client = paramiko.SSHClient()
|
client = paramiko.SSHClient()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
try:
|
try:
|
||||||
client.connect(
|
self._host_key.connect(
|
||||||
self.hostname,
|
client,
|
||||||
port=self.port,
|
|
||||||
username=self.username,
|
username=self.username,
|
||||||
password=self.password or None,
|
password=self.password or None,
|
||||||
key_filename=self._key_file,
|
key_filename=self._key_file,
|
||||||
@@ -95,7 +145,10 @@ class BsdDriver(OSDriver):
|
|||||||
look_for_keys=self._look_for_keys,
|
look_for_keys=self._look_for_keys,
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
except (paramiko.SSHException, socket.error) as exc:
|
except SshHostKeyMismatch:
|
||||||
|
client.close() # refused before any login; nothing was sent
|
||||||
|
raise
|
||||||
|
except (OSError, paramiko.SSHException) as exc:
|
||||||
client.close()
|
client.close()
|
||||||
raise ConnectionException(f"SSH to {self.hostname} failed: {exc}") from exc
|
raise ConnectionException(f"SSH to {self.hostname} failed: {exc}") from exc
|
||||||
self._client = client
|
self._client = client
|
||||||
@@ -123,7 +176,7 @@ class BsdDriver(OSDriver):
|
|||||||
self._root = run_on_transport(self._transport(), "id -u").stdout.strip() == "0"
|
self._root = run_on_transport(self._transport(), "id -u").stdout.strip() == "0"
|
||||||
return self._root
|
return self._root
|
||||||
|
|
||||||
def _privileged(self, command: str, privileged: bool) -> tuple[str, Optional[bytes]]:
|
def _privileged(self, command: str, privileged: bool) -> tuple[str, bytes | None]:
|
||||||
"""The command line that runs *command* as asked, and what goes to stdin first.
|
"""The command line that runs *command* as asked, and what goes to stdin first.
|
||||||
|
|
||||||
With a sudo password, ``sudo -S`` reads it from stdin, so it never
|
With a sudo password, ``sudo -S`` reads it from stdin, so it never
|
||||||
@@ -142,22 +195,96 @@ class BsdDriver(OSDriver):
|
|||||||
*,
|
*,
|
||||||
privileged: bool = False,
|
privileged: bool = False,
|
||||||
timeout: float = 60,
|
timeout: float = 60,
|
||||||
stdin: Optional[bytes] = None,
|
stdin: bytes | None = None,
|
||||||
) -> CommandResult:
|
) -> CommandResult:
|
||||||
"""Run *command* on an exec channel: no PTY, stderr apart, a real exit code."""
|
"""Run *command* on an exec channel: no PTY, stderr apart, a real exit code.
|
||||||
|
|
||||||
|
:raises ValueError: for *stdin* to a privileged command while a sudo
|
||||||
|
password is set (see ``_PRIVILEGED_STDIN``).
|
||||||
|
"""
|
||||||
line, prefix = self._privileged(command, privileged)
|
line, prefix = self._privileged(command, privileged)
|
||||||
data = (prefix or b"") + (stdin or b"") if (prefix or stdin) else None
|
if prefix and stdin is not None:
|
||||||
return run_on_transport(self._transport(), line, stdin=data, timeout=timeout)
|
raise ValueError(_PRIVILEGED_STDIN)
|
||||||
|
return run_on_transport(self._transport(), line, stdin=prefix or stdin, timeout=timeout)
|
||||||
|
|
||||||
|
def _install_file_as_root(self, path: str, content: str) -> CommandResult:
|
||||||
|
"""Replace *path* with *content*, as a new file owned by root, mode 644.
|
||||||
|
|
||||||
|
The content is staged without privilege (``_STAGE``), so it never follows
|
||||||
|
a sudo password on stdin. Root then installs the staged file next to *path*
|
||||||
|
and renames it over *path*; nothing root runs follows a symlink at *path*.
|
||||||
|
Nothing runs as root when staging failed.
|
||||||
|
"""
|
||||||
|
staged = self.run_command(_STAGE, timeout=30, stdin=content.encode())
|
||||||
|
lines = staged.stdout.strip().splitlines()
|
||||||
|
tmp = lines[-1] if lines else ""
|
||||||
|
if (
|
||||||
|
staged.exit_code != 0
|
||||||
|
or not tmp.startswith(_STAGE_PREFIX)
|
||||||
|
or "/" in tmp[len(_STAGE_PREFIX) :]
|
||||||
|
):
|
||||||
|
reason = (staged.stderr or staged.stdout).strip() or f"could not stage {path}"
|
||||||
|
return CommandResult(reason, "", staged.exit_code or 1)
|
||||||
|
tmp_q, part_q, path_q = (quote(p) for p in (tmp, f"{path}.napalm-tmp", path))
|
||||||
|
directory = quote(path.rsplit("/", 1)[0] or "/")
|
||||||
|
return self.run_command(
|
||||||
|
f"mkdir -p {directory} && install -m 644 -o root -g wheel {tmp_q} {part_q}"
|
||||||
|
f" && mv -f {part_q} {path_q}; s=$?; rm -f {tmp_q} {part_q}; exit $s",
|
||||||
|
privileged=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream:
|
def open_stream(self, command: str, *, privileged: bool = False) -> ByteStream:
|
||||||
line, prefix = self._privileged(command, privileged)
|
line, prefix = self._privileged(command, privileged)
|
||||||
return open_stream_on_transport(self._transport(), line, stdin_prefix=prefix)
|
return open_stream_on_transport(self._transport(), line, stdin_prefix=prefix)
|
||||||
|
|
||||||
|
def _read(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
privileged: bool = False,
|
||||||
|
timeout: float = 60,
|
||||||
|
ok: tuple[int, ...] = (0,),
|
||||||
|
) -> CommandResult:
|
||||||
|
"""Run a reader's command; raise when it did not read.
|
||||||
|
|
||||||
|
A reader that cannot read raises rather than return "nothing": an empty
|
||||||
|
update list would close every patch clock netOrk keeps (napalm-bsd#4).
|
||||||
|
"""
|
||||||
|
result = self.run_command(command, privileged=privileged, timeout=timeout)
|
||||||
|
if result.exit_code not in ok:
|
||||||
|
reason = (result.stderr or result.stdout).strip() or f"exit {result.exit_code}"
|
||||||
|
raise RuntimeError(f"{command}: {reason}")
|
||||||
|
return result
|
||||||
|
|
||||||
def _out(self, command: str, *, privileged: bool = False, timeout: float = 60) -> str:
|
def _out(self, command: str, *, privileged: bool = False, timeout: float = 60) -> str:
|
||||||
"""What *command* printed. A failing command prints nothing useful,
|
"""What *command* printed. A failing command prints nothing useful,
|
||||||
and the readers below treat empty output as "nothing there"."""
|
and the readers below treat empty output as "nothing there"."""
|
||||||
return self.run_command(command, privileged=privileged, timeout=timeout).stdout.strip()
|
return self.run_command(command, privileged=privileged, timeout=timeout).stdout.strip()
|
||||||
|
|
||||||
|
#: A file that exists until the host's first boot is over; "" when there is none to ask.
|
||||||
|
FIRST_BOOT_MARKER = ""
|
||||||
|
|
||||||
|
def first_boot_pending(self) -> bool:
|
||||||
|
"""Whether the host is still in its first boot.
|
||||||
|
|
||||||
|
A FreeBSD cloud image upgrades its base system on the first boot, starts
|
||||||
|
sshd only after that and restarts right away; ``/etc/rc`` removes
|
||||||
|
``/firstboot`` just before the restart. netOrk waits for this before it
|
||||||
|
sets up a new VM (NetOrk/netork#795).
|
||||||
|
"""
|
||||||
|
if not self.FIRST_BOOT_MARKER:
|
||||||
|
return False
|
||||||
|
return self.run_command(f"test -e {self.FIRST_BOOT_MARKER}").exit_code == 0
|
||||||
|
|
||||||
|
def _run_host_status_command(self, command: str) -> str:
|
||||||
|
"""The transport for ``HostStatusMixin.get_host_status``: read-only, no root.
|
||||||
|
|
||||||
|
On FreeBSD the report compares ``freebsd-version -k`` with ``-r``
|
||||||
|
(napalm-device-types 4.1); elsewhere "reboot required" stays unknown.
|
||||||
|
"""
|
||||||
|
return self.run_command(command).stdout
|
||||||
|
|
||||||
# -- facts -------------------------------------------------------------------
|
# -- facts -------------------------------------------------------------------
|
||||||
|
|
||||||
def _platform(self) -> dict[str, str]:
|
def _platform(self) -> dict[str, str]:
|
||||||
@@ -344,9 +471,72 @@ 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"
|
||||||
|
|
||||||
|
def get_management_peer(self) -> str | None:
|
||||||
|
"""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
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
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) -> dict[str, Any] | None:
|
||||||
|
"""net-snmp's community and port, if its daemon runs; None otherwise."""
|
||||||
|
if not self._out(f"pgrep -f {self.SNMPD_DAEMON}"):
|
||||||
|
return None
|
||||||
|
# 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 (
|
||||||
|
"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_os_users(self) -> list[dict[str, Any]]:
|
||||||
return parse.users(self._out("cat /etc/passwd"), self._out("cat /etc/group"))
|
return parse.users(self._out("cat /etc/passwd"), self._out("cat /etc/group"))
|
||||||
|
|
||||||
def get_processes(self) -> list[dict[str, Any]]:
|
def get_processes(self) -> list[dict[str, Any]]:
|
||||||
|
|||||||
+31
-9
@@ -31,12 +31,15 @@ class FreeBSDDriver(BsdDriver):
|
|||||||
ROUTES_COMMAND = "netstat -rnW"
|
ROUTES_COMMAND = "netstat -rnW"
|
||||||
# sockstat names the process of every socket it can see; root sees them all.
|
# sockstat names the process of every socket it can see; root sees them all.
|
||||||
LISTENING_COMMAND = "sockstat -46lq -P tcp,udp"
|
LISTENING_COMMAND = "sockstat -46lq -P tcp,udp"
|
||||||
|
# /etc/rc removes it at the end of the first boot.
|
||||||
|
FIRST_BOOT_MARKER = "/firstboot"
|
||||||
|
|
||||||
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]:
|
def _parse_listening(self, output: str, *, attributed: bool) -> list[dict]:
|
||||||
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,13 +47,26 @@ 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)
|
||||||
|
|
||||||
def _has_pkg(self) -> bool:
|
def _has_pkg(self) -> bool:
|
||||||
"""pkg is bootstrapped; a hand-installed classic system may have only the stub."""
|
"""pkg is bootstrapped; a hand-installed classic system may have only the stub.
|
||||||
return self.run_command("pkg -N").exit_code == 0
|
|
||||||
|
Only the stub's "not installed" means no pkg; any other failure (a
|
||||||
|
database pkg cannot read) raises rather than read as "no packages".
|
||||||
|
"""
|
||||||
|
result = self.run_command("pkg -N")
|
||||||
|
if result.exit_code == 0:
|
||||||
|
return True
|
||||||
|
message = (result.stderr or result.stdout).strip()
|
||||||
|
if "is not installed" in message:
|
||||||
|
return False
|
||||||
|
raise RuntimeError(f"pkg -N: {message or f'exit {result.exit_code}'}")
|
||||||
|
|
||||||
def get_packages(self) -> list[dict]:
|
def get_packages(self) -> list[dict]:
|
||||||
return parse.pkg_query(self._out(self.PKG_QUERY)) if self._has_pkg() else []
|
return parse.pkg_query(self._out(self.PKG_QUERY)) if self._has_pkg() else []
|
||||||
@@ -65,10 +81,13 @@ class FreeBSDDriver(BsdDriver):
|
|||||||
"""
|
"""
|
||||||
updates: list[dict] = []
|
updates: list[dict] = []
|
||||||
if self._has_pkg():
|
if self._has_pkg():
|
||||||
updates = parse.pkg_upgrades(self._out("pkg upgrade -n", privileged=True, timeout=300))
|
upgrade = self._read("pkg upgrade -n", privileged=True, timeout=300)
|
||||||
|
updates = parse.pkg_upgrades(upgrade.stdout)
|
||||||
audit = self.run_command("pkg audit -Fq", privileged=True, timeout=120)
|
audit = self.run_command("pkg audit -Fq", privileged=True, timeout=120)
|
||||||
known = audit.exit_code in (0, 1) # 1: vulnerable packages found
|
vulnerable = parse.pkg_audit(audit.stdout)
|
||||||
vulnerable = parse.pkg_audit(audit.stdout) if known else set()
|
# 1 is "vulnerable packages found" and any error alike; only a list
|
||||||
|
# of packages makes it a verdict.
|
||||||
|
known = audit.exit_code == 0 or (audit.exit_code == 1 and bool(vulnerable))
|
||||||
for update in updates:
|
for update in updates:
|
||||||
update["security"] = (update["name"] in vulnerable) if known else None
|
update["security"] = (update["name"] in vulnerable) if known else None
|
||||||
if "FreeBSD-base" in self._out("pkg repos -l").split():
|
if "FreeBSD-base" in self._out("pkg repos -l").split():
|
||||||
@@ -82,10 +101,13 @@ class FreeBSDDriver(BsdDriver):
|
|||||||
2 when there are none; it does not fetch, which ``freebsd-update cron``
|
2 when there are none; it does not fetch, which ``freebsd-update cron``
|
||||||
does daily where it is enabled.
|
does daily where it is enabled.
|
||||||
"""
|
"""
|
||||||
ready = self.run_command(
|
ready = self._read(
|
||||||
"freebsd-update --not-running-from-cron updatesready", privileged=True, timeout=60
|
"freebsd-update --not-running-from-cron updatesready",
|
||||||
|
privileged=True,
|
||||||
|
timeout=60,
|
||||||
|
ok=(0, 2),
|
||||||
)
|
)
|
||||||
if ready.exit_code != 0:
|
if ready.exit_code == 2:
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -61,10 +64,10 @@ class OpenBSDDriver(BsdDriver):
|
|||||||
updates: list[dict] = [
|
updates: list[dict] = [
|
||||||
{**candidate, "origin": None, "security": None}
|
{**candidate, "origin": None, "security": None}
|
||||||
for candidate in parse.pkg_add_candidates(
|
for candidate in parse.pkg_add_candidates(
|
||||||
self._out("pkg_add -u -n -v", privileged=True, timeout=300)
|
self._read("pkg_add -u -n -v", privileged=True, timeout=300).stdout
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
patches = parse.syspatch(self._out("syspatch -c", privileged=True, timeout=120))
|
patches = parse.syspatch(self._read("syspatch -c", privileged=True, timeout=120).stdout)
|
||||||
if patches:
|
if patches:
|
||||||
installed = parse.syspatch(self._out("syspatch -l", privileged=True))
|
installed = parse.syspatch(self._out("syspatch -l", privileged=True))
|
||||||
summary = (
|
summary = (
|
||||||
|
|||||||
+15
-10
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
_IFACE_HEADER = re.compile(
|
_IFACE_HEADER = re.compile(
|
||||||
r"^(?P<name>[^\s:]+): flags=[0-9a-f]+<(?P<flags>[^>]*)>(?:.*?\bmtu (?P<mtu>\d+))?"
|
r"^(?P<name>[^\s:]+): flags=[0-9a-f]+<(?P<flags>[^>]*)>(?:.*?\bmtu (?P<mtu>\d+))?"
|
||||||
@@ -26,7 +26,7 @@ def _prefix_from_netmask(mask: str) -> int:
|
|||||||
def ifconfig(text: str) -> dict[str, dict[str, Any]]:
|
def ifconfig(text: str) -> dict[str, dict[str, Any]]:
|
||||||
"""``ifconfig -a`` -> per interface: flags, MTU, MAC, state, speed, addresses."""
|
"""``ifconfig -a`` -> per interface: flags, MTU, MAC, state, speed, addresses."""
|
||||||
interfaces: dict[str, dict[str, Any]] = {}
|
interfaces: dict[str, dict[str, Any]] = {}
|
||||||
current: Optional[dict[str, Any]] = None
|
current: dict[str, Any] | None = None
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
header = _IFACE_HEADER.match(line)
|
header = _IFACE_HEADER.match(line)
|
||||||
if header:
|
if header:
|
||||||
@@ -69,7 +69,7 @@ def ifconfig(text: str) -> dict[str, dict[str, Any]]:
|
|||||||
return interfaces
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
def _network(destination: str, family: str) -> Optional[str]:
|
def _network(destination: str, family: str) -> str | None:
|
||||||
"""A netstat destination as a network: ``default``, ``10.0.2/24``, ``fe80::%em0/64``."""
|
"""A netstat destination as a network: ``default``, ``10.0.2/24``, ``fe80::%em0/64``."""
|
||||||
if destination == "default":
|
if destination == "default":
|
||||||
return "0.0.0.0/0" if family == "ipv4" else "::/0"
|
return "0.0.0.0/0" if family == "ipv4" else "::/0"
|
||||||
@@ -93,7 +93,7 @@ def routes(text: str) -> list[dict[str, str]]:
|
|||||||
"""
|
"""
|
||||||
result: list[dict[str, str]] = []
|
result: list[dict[str, str]] = []
|
||||||
family = ""
|
family = ""
|
||||||
iface_column: Optional[int] = None
|
iface_column: int | None = None
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
words = line.split()
|
words = line.split()
|
||||||
if not words:
|
if not words:
|
||||||
@@ -253,15 +253,20 @@ def crontab(text: str, *, system: bool = False, user: str = "") -> list[dict[str
|
|||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
def boottime(text: str) -> Optional[int]:
|
def boottime(text: str) -> int | None:
|
||||||
"""``sysctl -n kern.boottime``: FreeBSD ``{ sec = N, … }``, OpenBSD ``N``."""
|
"""``sysctl -n kern.boottime``: FreeBSD ``{ sec = N, … }``, OpenBSD ``N``."""
|
||||||
match = re.search(r"sec = (\d+)", text) or re.fullmatch(r"\s*(\d+)\s*", text)
|
match = re.search(r"sec = (\d+)", text) or re.fullmatch(r"\s*(\d+)\s*", text)
|
||||||
return int(match[1]) if match else None
|
return int(match[1]) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
#: The wildcard addresses a socket listens on: reported as data, never bound to.
|
||||||
|
_ANY_V4 = "0.0.0.0" # nosec B104
|
||||||
|
_ANY_V6 = "::"
|
||||||
|
|
||||||
|
|
||||||
def _socket(
|
def _socket(
|
||||||
proto: str, local: str, family: str, process: Optional[str], pid: Optional[int]
|
proto: str, local: str, family: str, process: str | None, pid: int | None
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> dict[str, Any] | None:
|
||||||
"""A ListeningSocketDict from a local endpoint: ``*:22``, ``[::1]:25``,
|
"""A ListeningSocketDict from a local endpoint: ``*:22``, ``[::1]:25``,
|
||||||
``127.0.0.1:25``, ``[fe80::1%lo0]:25`` (sockstat, fstat) or ``*.22``,
|
``127.0.0.1:25``, ``[fe80::1%lo0]:25`` (sockstat, fstat) or ``*.22``,
|
||||||
``::1.25``, ``fe80::1%lo0.25`` (netstat)."""
|
``::1.25``, ``fe80::1%lo0.25`` (netstat)."""
|
||||||
@@ -275,7 +280,7 @@ def _socket(
|
|||||||
return None
|
return None
|
||||||
address, _, zone = address.partition("%")
|
address, _, zone = address.partition("%")
|
||||||
if address == "*":
|
if address == "*":
|
||||||
address = "0.0.0.0" if family == "4" else "::"
|
address = _ANY_V4 if family == "4" else _ANY_V6
|
||||||
return {
|
return {
|
||||||
"proto": proto,
|
"proto": proto,
|
||||||
"address": address,
|
"address": address,
|
||||||
@@ -288,9 +293,9 @@ def _socket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _unique(sockets: list[Optional[dict[str, Any]]]) -> list[dict[str, Any]]:
|
def _unique(sockets: list[dict[str, Any] | None]) -> list[dict[str, Any]]:
|
||||||
"""One entry per socket, the first process holding it, as ss reports it."""
|
"""One entry per socket, the first process holding it, as ss reports it."""
|
||||||
seen: set[tuple] = set()
|
seen: set[tuple[Any, ...]] = set()
|
||||||
result = []
|
result = []
|
||||||
for s in sockets:
|
for s in sockets:
|
||||||
if s is None:
|
if s is None:
|
||||||
|
|||||||
Whitespace-only changes.
+43
-6
@@ -1,14 +1,14 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68", "wheel"]
|
requires = ["setuptools>=77"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "napalm-bsd"
|
name = "napalm-bsd"
|
||||||
version = "0.1.0"
|
version = "0.5.0"
|
||||||
description = "NAPALM drivers for FreeBSD and OpenBSD hosts via SSH"
|
description = "NAPALM drivers for FreeBSD and OpenBSD hosts via SSH"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = { text = "Apache-2.0" }
|
license = "Apache-2.0"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Christian Manivong", email = "christian@manivong.de" },
|
{ name = "Christian Manivong", email = "christian@manivong.de" },
|
||||||
]
|
]
|
||||||
@@ -16,22 +16,23 @@ keywords = ["napalm", "network", "automation", "freebsd", "openbsd", "bsd", "ssh
|
|||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: System Administrators",
|
"Intended Audience :: System Administrators",
|
||||||
"License :: OSI Approved :: Apache Software License",
|
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
"Topic :: System :: Systems Administration",
|
"Topic :: System :: Systems Administration",
|
||||||
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napalm>=4.0",
|
"napalm>=4.0",
|
||||||
"napalm-device-types>=2.6.0", # channel.run_on_transport
|
"napalm-device-types>=4.21.1",
|
||||||
"paramiko>=5.0.0", # CVE-2026-44405
|
"paramiko>=5.0.0", # CVE-2026-44405
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest", "pytest-cov", "ruff", "mypy"]
|
dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6"]
|
||||||
|
|
||||||
[project.entry-points."napalm.drivers"]
|
[project.entry-points."napalm.drivers"]
|
||||||
freebsd = "napalm_bsd:FreeBSDDriver"
|
freebsd = "napalm_bsd:FreeBSDDriver"
|
||||||
@@ -39,11 +40,47 @@ openbsd = "napalm_bsd:OpenBSDDriver"
|
|||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://git.netork.io/NAPALM/napalm-bsd"
|
Repository = "https://git.netork.io/NAPALM/napalm-bsd"
|
||||||
|
Issues = "https://git.netork.io/NAPALM/napalm-bsd/issues"
|
||||||
|
Changelog = "https://git.netork.io/NAPALM/napalm-bsd/src/branch/main/CHANGELOG.md"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
include = ["napalm_bsd*"]
|
include = ["napalm_bsd*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
napalm_bsd = ["py.typed"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# Recorded device output keeps its lines.
|
||||||
|
"tests/**" = ["E501"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.10"
|
||||||
|
strict = true
|
||||||
|
# napalm itself is untyped, so every driver subclasses Any.
|
||||||
|
disallow_subclassing_any = false
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["napalm.*", "paramiko.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
# Not yet under strict mypy (quality baseline); may only shrink.
|
||||||
|
module = ["napalm_bsd.base", "napalm_bsd.freebsd", "napalm_bsd.openbsd"]
|
||||||
|
ignore_errors = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["napalm_bsd"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 94
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"coverage_floor": 94.0,
|
||||||
|
"function_complexity": {
|
||||||
|
"napalm_bsd/parse.py::ifconfig": 21,
|
||||||
|
"napalm_bsd/parse.py::routes": 21,
|
||||||
|
"napalm_bsd/parse.py::users": 11
|
||||||
|
},
|
||||||
|
"function_lines": {},
|
||||||
|
"mypy_relaxed": [
|
||||||
|
"napalm_bsd.base",
|
||||||
|
"napalm_bsd.freebsd",
|
||||||
|
"napalm_bsd.openbsd"
|
||||||
|
],
|
||||||
|
"python_file_lines": {},
|
||||||
|
"untyped_public": []
|
||||||
|
}
|
||||||
+287
-7
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from napalm.base import NetworkDriver
|
from napalm.base import NetworkDriver
|
||||||
from napalm_device_types import OSDriver
|
from napalm_device_types import OSDriver
|
||||||
|
|
||||||
from napalm_bsd import FreeBSDDriver, OpenBSDDriver
|
from napalm_bsd import FreeBSDDriver, OpenBSDDriver, base
|
||||||
from napalm_bsd.base import BsdDriver
|
from napalm_bsd.base import BsdDriver
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures"
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
@@ -96,7 +96,7 @@ class TestIdentity:
|
|||||||
"get_route_to",
|
"get_route_to",
|
||||||
"get_arp_table",
|
"get_arp_table",
|
||||||
"get_lldp_neighbors",
|
"get_lldp_neighbors",
|
||||||
"get_users",
|
"get_os_users",
|
||||||
"get_processes",
|
"get_processes",
|
||||||
"get_cron_jobs",
|
"get_cron_jobs",
|
||||||
],
|
],
|
||||||
@@ -104,6 +104,13 @@ class TestIdentity:
|
|||||||
def test_implements_what_the_poll_reads(self, cls, getter):
|
def test_implements_what_the_poll_reads(self, cls, getter):
|
||||||
"""netOrk asks whether a getter is overridden, not merely present."""
|
"""netOrk asks whether a getter is overridden, not merely present."""
|
||||||
assert getattr(cls, getter) is not getattr(NetworkDriver, getter, None)
|
assert getattr(cls, getter) is not getattr(NetworkDriver, getter, None)
|
||||||
|
assert getattr(cls, getter) is not getattr(OSDriver, getter, None)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cls", [FreeBSDDriver, OpenBSDDriver])
|
||||||
|
def test_leaves_napalms_get_users_alone(self, cls):
|
||||||
|
"""NAPALM's get_users is a different getter; the host's accounts are get_os_users."""
|
||||||
|
assert "get_users" not in vars(BsdDriver)
|
||||||
|
assert "get_users" not in vars(cls)
|
||||||
|
|
||||||
|
|
||||||
class TestFacts:
|
class TestFacts:
|
||||||
@@ -177,8 +184,8 @@ class TestRoutesAndNeighbours:
|
|||||||
|
|
||||||
|
|
||||||
class TestAccountsAndProcesses:
|
class TestAccountsAndProcesses:
|
||||||
def test_users(self):
|
def test_os_users(self):
|
||||||
users = _freebsd_host().get_users()
|
users = _freebsd_host().get_os_users()
|
||||||
assert next(u for u in users if u["username"] == "root")["groups"] == ["wheel", "operator"]
|
assert next(u for u in users if u["username"] == "root")["groups"] == ["wheel", "operator"]
|
||||||
|
|
||||||
def test_processes(self):
|
def test_processes(self):
|
||||||
@@ -267,6 +274,15 @@ class TestPackages:
|
|||||||
assert driver.get_packages() == []
|
assert driver.get_packages() == []
|
||||||
assert driver.calls == [("pkg -N", False)]
|
assert driver.calls == [("pkg -N", False)]
|
||||||
|
|
||||||
|
def test_a_pkg_that_cannot_read_its_database_raises(self):
|
||||||
|
"""Any other failure is "could not read", never "no packages"."""
|
||||||
|
driver = _channel(
|
||||||
|
FreeBSDDriver,
|
||||||
|
{"pkg -N": ("pkg: sqlite error ...: database disk image is malformed", 1)},
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="malformed"):
|
||||||
|
driver.get_packages()
|
||||||
|
|
||||||
def test_openbsd_packages(self):
|
def test_openbsd_packages(self):
|
||||||
driver = _channel(OpenBSDDriver, {"pkg_info": _read("openbsd-vm", "pkg_info_full.txt")})
|
driver = _channel(OpenBSDDriver, {"pkg_info": _read("openbsd-vm", "pkg_info_full.txt")})
|
||||||
assert any(p["name"] == "pcre2" for p in driver.get_packages())
|
assert any(p["name"] == "pcre2" for p in driver.get_packages())
|
||||||
@@ -274,7 +290,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 +307,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)})
|
||||||
@@ -355,7 +375,7 @@ class TestAvailableUpdates:
|
|||||||
driver = _channel(
|
driver = _channel(
|
||||||
FreeBSDDriver,
|
FreeBSDDriver,
|
||||||
{
|
{
|
||||||
"pkg -N": ("", 1),
|
"pkg -N": ("pkg: pkg is not installed", 1),
|
||||||
("freebsd-update --not-running-from-cron updatesready", True): ("", 2),
|
("freebsd-update --not-running-from-cron updatesready", True): ("", 2),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -437,3 +457,263 @@ 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. Set up through napalm-device-types'
|
||||||
|
``configure_snmp_agent`` template; every value is the caller's (#12)."""
|
||||||
|
|
||||||
|
STAGED = "/tmp/napalm-bsd-stage.Ab12Cd"
|
||||||
|
SETTINGS = {"community": "c0mm", "allowed_sources": ["10.7.224.20"], "contact": "ops"}
|
||||||
|
|
||||||
|
@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_installs_configures_and_starts_net_snmp_with_the_callers_values(
|
||||||
|
self, cls, install, conf, start
|
||||||
|
):
|
||||||
|
driver = _channel(cls, {base._STAGE: self.STAGED})
|
||||||
|
stdin_seen = []
|
||||||
|
run = driver.run_command
|
||||||
|
|
||||||
|
def recording(command, *, privileged=False, timeout=60, stdin=None):
|
||||||
|
if stdin is not None:
|
||||||
|
stdin_seen.append((command, privileged, stdin))
|
||||||
|
return run(command, privileged=privileged, timeout=timeout, stdin=stdin)
|
||||||
|
|
||||||
|
driver.run_command = recording # type: ignore[method-assign]
|
||||||
|
result = driver.configure_snmp_agent(self.SETTINGS)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert [s["step"] for s in result["steps"]] == ["install", "configure", "firewall", "start"]
|
||||||
|
commands = [c for c, _ in driver.calls]
|
||||||
|
assert commands[0] == install
|
||||||
|
installed = next(c for c, p in driver.calls if p and "install -m 644" in c)
|
||||||
|
assert conf in installed and self.STAGED in installed
|
||||||
|
assert commands[-1] == start
|
||||||
|
# The configuration reaches the host only through the unprivileged stage (#8).
|
||||||
|
assert [(c, p) for c, p, _ in stdin_seen] == [(base._STAGE, False)]
|
||||||
|
written = stdin_seen[0][2].decode()
|
||||||
|
assert "rocommunity c0mm 10.7.224.20/32" in written
|
||||||
|
assert "sysContact ops" in written
|
||||||
|
assert "public" not in written and "netork" not in written.lower()
|
||||||
|
|
||||||
|
def test_a_failed_stage_runs_nothing_as_root(self):
|
||||||
|
driver = _channel(FreeBSDDriver, {base._STAGE: ("mktemp: permission denied", 1)})
|
||||||
|
result = driver.configure_snmp_agent(self.SETTINGS)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert not any(p and "install -m 644" in c for c, p in driver.calls)
|
||||||
|
|
||||||
|
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.configure_snmp_agent(self.SETTINGS)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "No packages available" in result["output"]
|
||||||
|
assert len(driver.calls) == 1
|
||||||
|
|
||||||
|
def test_the_host_firewall_is_left_to_the_operator(self):
|
||||||
|
result = _channel(FreeBSDDriver, {base._STAGE: self.STAGED}).configure_snmp_agent(
|
||||||
|
self.SETTINGS
|
||||||
|
)
|
||||||
|
firewall = next(s for s in result["steps"] if s["step"] == "firewall")
|
||||||
|
assert firewall["ok"] is True and "pf" in firewall["output"]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("community", ["two words", 'q"uote', ""])
|
||||||
|
def test_a_community_that_would_break_the_file_is_refused(self, community):
|
||||||
|
driver = _channel(FreeBSDDriver, {base._STAGE: self.STAGED})
|
||||||
|
result = driver.configure_snmp_agent({"community": community, "allowed_sources": []})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert not any(p and "install -m 644" in c for c, p in driver.calls)
|
||||||
|
|
||||||
|
def test_run_device_action_is_gone(self):
|
||||||
|
assert not hasattr(BsdDriver, "run_device_action")
|
||||||
|
assert not hasattr(BsdDriver, "_action_fix_snmp")
|
||||||
|
|
||||||
|
def test_the_management_peer_is_the_ssh_client(self):
|
||||||
|
driver = _channel(FreeBSDDriver, {base._PEER_COMMAND: "10.7.224.20 51234 10.0.0.9 22"})
|
||||||
|
assert driver.get_management_peer() == "10.7.224.20"
|
||||||
|
|
||||||
|
def test_no_ssh_connection_variable_no_peer(self):
|
||||||
|
assert _channel(FreeBSDDriver, {}).get_management_peer() is None
|
||||||
|
|
||||||
|
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_a_community_that_is_not_in_the_file_is_not_assumed(self):
|
||||||
|
driver = _channel(
|
||||||
|
FreeBSDDriver,
|
||||||
|
{
|
||||||
|
"pgrep -f /usr/local/sbin/snmpd": "1234",
|
||||||
|
"cat /usr/local/etc/snmp/snmpd.conf": "agentAddress udp:161\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert driver.get_snmp_config()["community"] == ""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrivilegedStdin:
|
||||||
|
"""A privileged command never gets stdin while a sudo password is set (#8).
|
||||||
|
|
||||||
|
``sudo -S`` reads the password from stdin; a sudo that does not prompt
|
||||||
|
(NOPASSWD, a cached timestamp) leaves it for the command, which would write
|
||||||
|
it into whatever the data goes to."""
|
||||||
|
|
||||||
|
def _driver(self, monkeypatch, *, sudo_password, root=False):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
base,
|
||||||
|
"run_on_transport",
|
||||||
|
lambda transport, line, stdin=None, timeout=60: sent.append((line, stdin)) or _Result(),
|
||||||
|
)
|
||||||
|
args = {"sudo_password": sudo_password} if sudo_password else {}
|
||||||
|
driver = FreeBSDDriver("host", "netork", "", optional_args=args)
|
||||||
|
driver._transport = lambda: object() # type: ignore[method-assign]
|
||||||
|
driver._root = root
|
||||||
|
return driver, sent
|
||||||
|
|
||||||
|
def test_refuses_stdin_to_a_privileged_command_with_a_sudo_password(self, monkeypatch):
|
||||||
|
driver, sent = self._driver(monkeypatch, sudo_password="s3cret")
|
||||||
|
with pytest.raises(ValueError, match="no stdin"):
|
||||||
|
driver.run_command("cat > /etc/x", privileged=True, stdin=b"data")
|
||||||
|
assert sent == []
|
||||||
|
|
||||||
|
def test_a_privileged_command_without_stdin_gets_only_the_password(self, monkeypatch):
|
||||||
|
driver, sent = self._driver(monkeypatch, sudo_password="s3cret")
|
||||||
|
driver.run_command("id -u", privileged=True)
|
||||||
|
assert sent == [("sudo -S -p '' sh -c 'id -u'", b"s3cret\n")]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sudo_password, root", [(None, False), ("s3cret", True)])
|
||||||
|
def test_stdin_is_fine_where_no_password_goes_first(self, monkeypatch, sudo_password, root):
|
||||||
|
driver, sent = self._driver(monkeypatch, sudo_password=sudo_password, root=root)
|
||||||
|
driver.run_command("cat > /tmp/x", privileged=True, stdin=b"data")
|
||||||
|
assert sent[0][1] == b"data"
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostStatus:
|
||||||
|
"""napalm-device-types' host status, carried over the exec channel; on
|
||||||
|
FreeBSD it compares the installed with the running kernel (4.1.0)."""
|
||||||
|
|
||||||
|
REPORT = (
|
||||||
|
"HSTAT_BEGIN\n[kernel]\n15.1-RELEASE\n[modules]\n"
|
||||||
|
"[freebsd-kernel]\n15.1-RELEASE-p5\n[freebsd-running]\n15.1-RELEASE-p4\n"
|
||||||
|
"[timers]\nHSTAT_END\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_patched_kernel_waits_for_a_reboot(self):
|
||||||
|
from napalm_device_types.host_status import HOST_STATUS_COMMAND
|
||||||
|
|
||||||
|
driver = _channel(FreeBSDDriver, {HOST_STATUS_COMMAND: self.REPORT})
|
||||||
|
status = driver.get_host_status()
|
||||||
|
assert status["reboot_required"] is True
|
||||||
|
assert driver.calls == [(HOST_STATUS_COMMAND, False)] # read-only, no root
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cls", [FreeBSDDriver, OpenBSDDriver])
|
||||||
|
def test_both_have_it(self, cls):
|
||||||
|
assert callable(getattr(cls, "get_host_status", None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAFailedReadRaises:
|
||||||
|
"""A reader that could not read raises; [] would tell netOrk "no updates"
|
||||||
|
and close every patch clock on the host (napalm-bsd#4)."""
|
||||||
|
|
||||||
|
def test_freebsd_without_root(self):
|
||||||
|
driver = _channel(
|
||||||
|
FreeBSDDriver,
|
||||||
|
{"pkg -N": "", ("pkg upgrade -n", True): ("", 1)},
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="pkg upgrade -n"):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
def test_a_failed_audit_leaves_security_unknown(self):
|
||||||
|
"""pkg audit exits 1 for "vulnerable packages found" and for errors alike;
|
||||||
|
only a list of packages makes the 1 a verdict."""
|
||||||
|
driver = _channel(
|
||||||
|
FreeBSDDriver,
|
||||||
|
{
|
||||||
|
"pkg -N": "",
|
||||||
|
("pkg upgrade -n", True): _read("freebsd-vm", "sudo_pkg_upgrade_n_latest.txt"),
|
||||||
|
("pkg audit -Fq", True): ("", 1),
|
||||||
|
"pkg repos -l": "FreeBSD-ports\nFreeBSD-base\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert {u["security"] for u in driver.get_available_updates()} == {None}
|
||||||
|
|
||||||
|
def test_classic_freebsd_update_error(self):
|
||||||
|
driver = _channel(
|
||||||
|
FreeBSDDriver,
|
||||||
|
{
|
||||||
|
"pkg -N": ("pkg: pkg is not installed", 1),
|
||||||
|
("freebsd-update --not-running-from-cron updatesready", True): ("", 1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="freebsd-update"):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failing", ["pkg_add -u -n -v", "syspatch -c"])
|
||||||
|
def test_openbsd_without_root(self, failing):
|
||||||
|
answers = {("pkg_add -u -n -v", True): "", ("syspatch -c", True): ""}
|
||||||
|
answers[(failing, True)] = ("", 1)
|
||||||
|
driver = _channel(OpenBSDDriver, answers)
|
||||||
|
with pytest.raises(RuntimeError, match=failing.split()[0]):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFirstBoot:
|
||||||
|
"""Whether the host is still in its first boot (NetOrk/netork#795).
|
||||||
|
|
||||||
|
A FreeBSD cloud image upgrades its base system on the first boot, starts
|
||||||
|
sshd only then and restarts right after; /etc/rc removes /firstboot just
|
||||||
|
before that restart. netOrk waits for it before setting up a new VM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_freebsd_in_its_first_boot(self):
|
||||||
|
driver = _channel(FreeBSDDriver, {"test -e /firstboot": ("", 0)})
|
||||||
|
assert driver.first_boot_pending() is True
|
||||||
|
assert driver.calls == [("test -e /firstboot", False)]
|
||||||
|
|
||||||
|
def test_freebsd_after_its_first_boot(self):
|
||||||
|
driver = _channel(FreeBSDDriver, {"test -e /firstboot": ("", 1)})
|
||||||
|
assert driver.first_boot_pending() is False
|
||||||
|
|
||||||
|
def test_openbsd_has_no_marker_to_ask(self):
|
||||||
|
driver = _channel(OpenBSDDriver, {})
|
||||||
|
assert driver.first_boot_pending() is False
|
||||||
|
assert driver.calls == []
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""open() verifies the host key it is given, and reports the one it sees (NetOrk/netork#1167)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
import pytest
|
||||||
|
from napalm_device_types.ssh_host_keys import (
|
||||||
|
KNOWN_KEYS_ARG,
|
||||||
|
OBSERVER_ARG,
|
||||||
|
SshHostKey,
|
||||||
|
SshHostKeyMismatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
from napalm_bsd import FreeBSDDriver, base
|
||||||
|
|
||||||
|
SERVER_KEY = paramiko.ECDSAKey.generate()
|
||||||
|
OTHER_KEY = paramiko.ECDSAKey.generate()
|
||||||
|
|
||||||
|
|
||||||
|
class _HostKeys:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lines: list[str] = []
|
||||||
|
|
||||||
|
def load(self, path: str) -> None:
|
||||||
|
with open(path) as f:
|
||||||
|
self.lines += f.read().splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
class _Transport:
|
||||||
|
def get_remote_server_key(self):
|
||||||
|
return SERVER_KEY
|
||||||
|
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
"""paramiko.SSHClient as far as open() uses it; refuses like paramiko would."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.host_keys = _HostKeys()
|
||||||
|
self.policy = None
|
||||||
|
self.connected_with: dict | None = None
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def set_missing_host_key_policy(self, policy) -> None:
|
||||||
|
self.policy = policy
|
||||||
|
|
||||||
|
def get_host_keys(self) -> _HostKeys:
|
||||||
|
return self.host_keys
|
||||||
|
|
||||||
|
def connect(self, hostname, **kwargs) -> None:
|
||||||
|
if self.host_keys.lines and SshHostKey.of(SERVER_KEY).key not in self.host_keys.lines[0]:
|
||||||
|
raise paramiko.BadHostKeyException(hostname, SERVER_KEY, OTHER_KEY)
|
||||||
|
if not self.host_keys.lines:
|
||||||
|
self.policy.missing_host_key(self, hostname, SERVER_KEY)
|
||||||
|
self.connected_with = {"hostname": hostname, **kwargs}
|
||||||
|
|
||||||
|
def get_transport(self) -> _Transport:
|
||||||
|
return _Transport()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch) -> _Client:
|
||||||
|
fake = _Client()
|
||||||
|
monkeypatch.setattr(base.paramiko, "SSHClient", lambda: fake)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _driver(seen: list, known: SshHostKey | None = None) -> FreeBSDDriver:
|
||||||
|
args: dict = {"port": 2222, OBSERVER_ARG: seen.append}
|
||||||
|
if known is not None:
|
||||||
|
args[KNOWN_KEYS_ARG] = {"2222": known.as_dict()}
|
||||||
|
return FreeBSDDriver("10.0.0.7", "netork", "s3cret", optional_args=args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_use_connects_and_reports_the_key(client):
|
||||||
|
seen: list = []
|
||||||
|
_driver(seen).open()
|
||||||
|
assert client.connected_with["password"] == "s3cret"
|
||||||
|
assert client.connected_with["port"] == 2222
|
||||||
|
assert seen[0]["accepted"] is True
|
||||||
|
assert seen[0]["seen"]["fingerprint"] == SshHostKey.of(SERVER_KEY).fingerprint
|
||||||
|
assert (seen[0]["host"], seen[0]["port"]) == ("10.0.0.7", 2222)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_known_key_connects(client):
|
||||||
|
seen: list = []
|
||||||
|
_driver(seen, SshHostKey.of(SERVER_KEY)).open()
|
||||||
|
assert client.host_keys.lines == [SshHostKey.of(SERVER_KEY).known_hosts_line("10.0.0.7", 2222)]
|
||||||
|
assert client.connected_with is not None
|
||||||
|
assert seen[0]["accepted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_changed_key_is_refused_and_the_client_closed(client):
|
||||||
|
seen: list = []
|
||||||
|
with pytest.raises(SshHostKeyMismatch):
|
||||||
|
_driver(seen, SshHostKey.of(OTHER_KEY)).open()
|
||||||
|
assert client.connected_with is None
|
||||||
|
assert client.closed is True
|
||||||
|
assert seen[0]["accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_host_key_args_it_connects_as_before(client):
|
||||||
|
FreeBSDDriver("10.0.0.7", "netork", "s3cret").open()
|
||||||
|
assert client.connected_with is not None
|
||||||
Reference in new issue
Block a user