feat: SystemIdentityMixin reads a host's SMBIOS identity (4.9.0)
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 DMI tables. Both drivers that read DMI (napalm-linux, napalm-proxmox) had a copy of the shell for it, and neither read the board serial. SystemIdentityMixin.get_system_identity() reads product_uuid, product_serial and board_serial in one framed command and returns them with vendor placeholders empty: a Supermicro board's product serial is 0123456789 on every one of them, others read "To be filled by O.E.M." or "Default string", and many cheap boards share the UUID 03000200-0400-0500-0006-000700080009. is_placeholder_identity() is the same filter for a BMC driver's side. A driver supplies only _run_system_identity_command(command, privileged=); the files are root's.
This commit is contained in:
1 parent
a1da0337e2
commit
fde6d717a8
6 files changed
+247
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user