diff --git a/README.md b/README.md index 127497d..1f90cef 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,15 @@ A mechanism it could not read completely is `None` ("did not look"), a host with `[]`. The command is about 4 kB, so a driver sends it on an exec channel (`_run_virtual_ips_command(command, privileged=)`), not typed into a shell. +`SystemIdentityMixin` (`get_system_identity`) reads a host's SMBIOS identity -- system UUID, +product serial and board serial from `/sys/class/dmi/id` -- which is what ties a server to +its BMC (`ManagementControllerDriver.get_hardware_inventory` reports the same values from +the other side). Vendor placeholders (`0123456789` on Supermicro boards, `To be filled by +O.E.M.`, `Default string`, all-zero or the common `03000200-…-000700080009` UUID) read as +empty: matching on them would join unrelated machines. `is_placeholder_identity` is the +same filter for a BMC driver's side. `product_uuid` and `product_serial` are root's, so a +driver supplies `_run_system_identity_command(command, privileged=)`. + `OpenSshServerMixin` (`harden_ssh_server`, `get_ssh_server_algorithms`) takes algorithms out of what an OpenSSH server offers -- the ones ssh-audit flags. It reads the lists sshd really uses (`sshd -T`), takes out what was asked and writes the rest to diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index 2eb5b27..5eb32f9 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -125,6 +125,12 @@ from napalm_device_types.systemd import ( parse_systemd_services, ) from napalm_device_types.updates import UpdateMixin +from napalm_device_types.system_identity import ( + SYSTEM_IDENTITY_COMMAND, + SystemIdentityMixin, + is_placeholder_identity, + parse_system_identity, +) from napalm_device_types.virtual_ips import ( VIRTUAL_IPS_COMMAND, VirtualIpsMixin, @@ -205,6 +211,10 @@ __all__ = [ "UpdateMixin", "VIRTUAL_IPS_COMMAND", "VirtualIpsMixin", + "SystemIdentityMixin", + "SYSTEM_IDENTITY_COMMAND", + "is_placeholder_identity", + "parse_system_identity", "OpenSshServerMixin", "SSH_ALGORITHM_CLASSES", "SshHardeningPlan", diff --git a/napalm_device_types/models.py b/napalm_device_types/models.py index 1a5b375..cc5e41c 100644 --- a/napalm_device_types/models.py +++ b/napalm_device_types/models.py @@ -1151,3 +1151,13 @@ class ManagementProtocolsDict(TypedDict): snmp: ProtocolStateDict kvm: ProtocolStateDict virtual_media: ProtocolStateDict + + +class SystemIdentityDict(TypedDict): + """A host's SMBIOS identity, what ties it to its BMC (:mod:`napalm_device_types.system_identity`). + + Each value is empty when unknown or a vendor placeholder.""" + + system_uuid: str # lowercase + product_serial: str + board_serial: str diff --git a/napalm_device_types/system_identity.py b/napalm_device_types/system_identity.py new file mode 100644 index 0000000..045c8a2 --- /dev/null +++ b/napalm_device_types/system_identity.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""The SMBIOS identity of a host: system UUID, product serial, board serial. + +What ties a server to its BMC. The BMC reports the server's serial number and +system UUID; the host reads the same values from its firmware tables. Both sides +read them the same way on every Linux host, so the command, its parse and the +placeholder filter live here once, and a driver only carries the command across. + +**Placeholders are no identity.** Board vendors leave defaults in the DMI tables +that are the same on thousands of machines: a Supermicro board's product serial +is ``0123456789``, others read ``To be filled by O.E.M.`` or ``Default string``, +and a common UUID on cheap boards is ``03000200-0400-0500-0006-000700080009``. +Matching on those would join unrelated machines, so they read as empty. + +**Root.** ``product_uuid`` and ``product_serial`` are readable by root only; without +it they come back empty, which is "not known", not a mismatch. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from napalm_device_types.models import SystemIdentityDict +from napalm_device_types.terminal import strip_terminal_codes + +_BEGIN = "IDBEGIN" +_END = "IDEND" +_DMI = "/sys/class/dmi/id" + +#: Reads the three files in one round trip, framed so a banner or prompt around the +#: output cannot shift a field. +SYSTEM_IDENTITY_COMMAND = ( + f"printf '{_BEGIN}\\n%s\\n%s\\n%s\\n{_END}\\n' " + f"\"$(cat {_DMI}/product_uuid 2>/dev/null)\" " + f"\"$(cat {_DMI}/product_serial 2>/dev/null)\" " + f"\"$(cat {_DMI}/board_serial 2>/dev/null)\"" +) + +#: Lowercased defaults board vendors leave in place. +_PLACEHOLDERS = frozenset({ + "", "none", "n/a", "na", "not specified", "not applicable", "not available", + "to be filled by o.e.m.", "to be filled by oem", "default string", "default", + "system serial number", "base board serial number", "chassis serial number", + "serial number", "0123456789", "1234567890", "123456789", "o.e.m.", "oem", + "03000200-0400-0500-0006-000700080009", +}) +#: A value of one repeated character ("0", "00000000", "FFFF-...") or of zeros and Fs. +_REPEATED = re.compile(r"^(.)\1*$") + + +def is_placeholder_identity(value: str | None) -> bool: + """Whether *value* is a vendor default rather than this machine's identity.""" + text = (value or "").strip().lower() + if text in _PLACEHOLDERS: + return True + compact = text.replace("-", "").replace(" ", "") + return bool(_REPEATED.match(compact)) + + +def _clean(value: str) -> str: + value = value.strip() + return "" if is_placeholder_identity(value) else value + + +def parse_system_identity(output: str) -> SystemIdentityDict: + """The identity from :data:`SYSTEM_IDENTITY_COMMAND`'s output, placeholders empty. + + :raises ValueError: when the output carries no intact frame. + """ + lines = strip_terminal_codes(output).splitlines() + try: + start = lines.index(_BEGIN) + 1 + end = lines.index(_END, start) + except ValueError: + raise ValueError("system identity: no intact IDBEGIN/IDEND frame") from None + fields = (lines[start:end] + ["", "", ""])[:3] + uuid, product_serial, board_serial = (_clean(f) for f in fields) + return { + "system_uuid": uuid.lower(), + "product_serial": product_serial, + "board_serial": board_serial, + } + + +class SystemIdentityMixin: + """Adds :meth:`get_system_identity` to a driver that can run a command on a Linux host. + + The template form (README, "Function classes"): command and parse are the same + everywhere and concrete here; a driver supplies only + :meth:`_run_system_identity_command` -- how a command reaches its host, and how it + gains root there. Mixed in by the drivers that can, so + ``hasattr(driver, "get_system_identity")`` stays a truthful answer. + """ + + if TYPE_CHECKING: # pragma: no cover - declared for type checkers only + + def _run_system_identity_command(self, command: str, *, privileged: bool) -> str: + """Run *command* on the host and return what it printed; as root when + *privileged* (``product_uuid`` and ``product_serial`` are root's).""" + ... + + def get_system_identity(self) -> SystemIdentityDict: + """ + Returns the host's SMBIOS identity, each value empty when unknown or a vendor + placeholder: + + * system_uuid (str) - lowercase + * product_serial (str) + * board_serial (str) + + :raises ValueError: if the output carried no intact reading. + """ + output = self._run_system_identity_command(SYSTEM_IDENTITY_COMMAND, privileged=True) + return parse_system_identity(output) diff --git a/pyproject.toml b/pyproject.toml index 2f7edf1..2b541c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-device-types" -version = "4.8.0" +version = "4.9.0" description = "Abstract device-type base classes for NAPALM drivers" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_system_identity.py b/tests/test_system_identity.py new file mode 100644 index 0000000..2dc8d8b --- /dev/null +++ b/tests/test_system_identity.py @@ -0,0 +1,102 @@ +"""SystemIdentityMixin: the SMBIOS identity of a host, for matching it to its BMC.""" + +from __future__ import annotations + +import pytest + +from napalm_device_types import ( + SYSTEM_IDENTITY_COMMAND, + SystemIdentityMixin, + is_placeholder_identity, + parse_system_identity, +) + +UUID = "31303043-534D-C40C-7A72-6F1300000000" + + +def _out(uuid: str, product_serial: str, board_serial: str) -> str: + return f"IDBEGIN\n{uuid}\n{product_serial}\n{board_serial}\nIDEND\n" + + +# ── parsing ─────────────────────────────────────────────────────────────────── + + +def test_reads_uuid_and_both_serials(): + assert parse_system_identity(_out(UUID, "S123", "ZM155S026022")) == { + "system_uuid": "31303043-534d-c40c-7a72-6f1300000000", + "product_serial": "S123", + "board_serial": "ZM155S026022", + } + + +def test_a_supermicro_placeholder_serial_is_dropped(): + # pve-dual, a Supermicro X10DRL-i: the product serial is the board vendor's default. + identity = parse_system_identity(_out(UUID, "0123456789", "ZM155S026022")) + assert identity["product_serial"] == "" + assert identity["board_serial"] == "ZM155S026022" + + +@pytest.mark.parametrize( + "value", + ["", "None", "N/A", "Not Specified", "To be filled by O.E.M.", "Default string", + "System Serial Number", "0123456789", "1234567890", "0", "00000000", "Not Applicable"], +) +def test_placeholders(value): + assert is_placeholder_identity(value) + + +@pytest.mark.parametrize( + "uuid", + ["00000000-0000-0000-0000-000000000000", "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + # A UUID many cheap boards ship, the same on every one of them. + "03000200-0400-0500-0006-000700080009"], +) +def test_placeholder_uuids(uuid): + assert parse_system_identity(_out(uuid, "", ""))["system_uuid"] == "" + + +def test_real_values_are_not_placeholders(): + assert not is_placeholder_identity("ZM155S026022") + assert not is_placeholder_identity(UUID) + + +def test_unreadable_files_read_as_empty(): + # product_serial and product_uuid are root's alone; without root they come back empty. + assert parse_system_identity(_out("", "", "BS1")) == { + "system_uuid": "", "product_serial": "", "board_serial": "BS1"} + + +def test_output_without_its_frame_is_not_a_reading(): + with pytest.raises(ValueError): + parse_system_identity("sudo: a password is required\n") + + +def test_terminal_noise_around_the_frame_is_ignored(): + assert parse_system_identity("\x1b[?2004h" + _out(UUID, "S1", "B1"))["board_serial"] == "B1" + + +# ── the mixin ───────────────────────────────────────────────────────────────── + + +class _Host(SystemIdentityMixin): + def __init__(self, output: str): + self.output = output + self.calls: list = [] + + def _run_system_identity_command(self, command: str, *, privileged: bool) -> str: + self.calls.append((command, privileged)) + return self.output + + +def test_the_driver_only_carries_the_command_across(): + host = _Host(_out(UUID, "S1", "B1")) + + assert host.get_system_identity()["board_serial"] == "B1" + ((command, privileged),) = host.calls + assert command == SYSTEM_IDENTITY_COMMAND + assert privileged is True + + +def test_the_command_reads_the_three_dmi_files(): + for name in ("product_uuid", "product_serial", "board_serial"): + assert f"/sys/class/dmi/id/{name}" in SYSTEM_IDENTITY_COMMAND