feat: Intel AMT driver over WS-Management (0.1.0)
An `amt` driver (napalm_amt.AmtDriver), a ManagementControllerDriver for the management controller of Intel vPro machines, with its own lean WS-Management client (requests + ElementTree, HTTP Digest, ports 16992/16993). It reads the machine's identity, hardware with the SMBIOS system UUID, firmware (AMT with build, BIOS), power state and the hardware event log, reports what AMT listens on (plain HTTP or TLS, KVM incl. port 5900, IDE-R), and switches the machine's power. AMT cannot be reset over WS-Management, so there is no reboot_host. Discovery recognises AMT by its ports and its Server header. Tested against Intel's published AMT 12 recordings (go-wsman-messages, Apache-2.0, see NOTICE). `python -m napalm_amt.record` records a real AMT read-only; the first test devices are two Lenovo ThinkCentre M910x.
This commit is contained in:
commit
0052ba7884
61 files changed
+4303
No files matched your search
@@ -0,0 +1,5 @@
|
||||
"""NAPALM driver for Intel Active Management Technology (AMT) over WS-Management."""
|
||||
|
||||
from napalm_amt.driver import AmtDriver
|
||||
|
||||
__all__ = ["AmtDriver"]
|
||||
@@ -0,0 +1,348 @@
|
||||
"""NAPALM driver for Intel Active Management Technology (AMT) over WS-Management.
|
||||
|
||||
AMT is the management controller built into Intel vPro client platforms (business desktops,
|
||||
mini PCs, notebooks). Like a server BMC it works while the operating system is down or the
|
||||
machine is off; unlike one it shares the machine's network controller, has no sensors to
|
||||
speak of, and cannot be restarted over the network.
|
||||
|
||||
The device is AMT. NAPALM's getters describe AMT (its network port, its version); the
|
||||
``ManagementControllerDriver`` methods describe and control the machine it manages. There
|
||||
is no ``reboot_host``: WS-Management offers no way to restart AMT itself.
|
||||
|
||||
Optional args
|
||||
-------------
|
||||
port : int
|
||||
16992 (default, plain HTTP) or 16993 (TLS, once TLS is set up in AMT).
|
||||
tls : bool
|
||||
Overrides the scheme the port implies.
|
||||
verify_ssl : bool or str
|
||||
With TLS: ``True`` (default) verifies AMT's certificate against the CA bundle of
|
||||
``requests``, a path verifies against that CA file, ``False`` turns verification off.
|
||||
retries : int
|
||||
Extra attempts after a connection error (default 1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from napalm.base.exceptions import ConnectionException
|
||||
from napalm_device_types import ManagementControllerDriver
|
||||
from napalm_device_types.base import FingerprintRule, PortSpec
|
||||
from napalm_device_types.models import POWER_ACTIONS
|
||||
|
||||
from napalm_amt.parsers import (
|
||||
POWER_REQUESTS,
|
||||
accepted_actions,
|
||||
parse_cpus,
|
||||
parse_drives,
|
||||
parse_ethernet_ports,
|
||||
parse_event_record,
|
||||
parse_firmware,
|
||||
parse_management_protocols,
|
||||
parse_memory,
|
||||
parse_platform_guid,
|
||||
parse_power_state,
|
||||
parse_software_identity,
|
||||
parse_system_identity,
|
||||
)
|
||||
from napalm_amt.parsers.common import is_true, text
|
||||
from napalm_amt.wsman import PORT, WsmanAuthError, WsmanError, WsmanFault, WsmanTransport
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
_JsonDict = dict[str, Any]
|
||||
_T = TypeVar("_T")
|
||||
|
||||
#: Records per ``AMT_MessageLog.GetRecords`` call; 390 is what Intel's tools ask for.
|
||||
_RECORDS_PER_CALL = 390
|
||||
#: GetRecords calls per log; AMT keeps a few hundred records, this stops a runaway loop.
|
||||
_MAX_RECORD_CALLS = 50
|
||||
#: ``PositionToFirstRecord`` ReturnValue for an empty log.
|
||||
_NO_RECORD = "2"
|
||||
#: ``RequestPowerStateChange`` ReturnValues that mean accepted: done, or job started.
|
||||
_POWER_ACCEPTED = {"0", "4096"}
|
||||
#: The machine AMT manages, as ``RequestPowerStateChange`` names it (from Intel's tools).
|
||||
_MANAGED_SYSTEM = (
|
||||
"<h:ManagedElement>"
|
||||
'<Address xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">'
|
||||
"http://schemas.xmlsoap.org/ws/2004/08/addressing</Address>"
|
||||
'<ReferenceParameters xmlns="http://schemas.xmlsoap.org/ws/2004/08/addressing">'
|
||||
'<ResourceURI xmlns="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd">'
|
||||
"http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ComputerSystem</ResourceURI>"
|
||||
'<SelectorSet xmlns="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd">'
|
||||
'<Selector Name="CreationClassName">CIM_ComputerSystem</Selector>'
|
||||
'<Selector Name="Name">ManagedSystem</Selector>'
|
||||
"</SelectorSet></ReferenceParameters></h:ManagedElement>"
|
||||
)
|
||||
|
||||
|
||||
class AmtDriver(ManagementControllerDriver):
|
||||
"""NAPALM driver for Intel AMT: reads the machine, and switches its power."""
|
||||
|
||||
platform = "amt"
|
||||
DRIVER_NAME = "amt"
|
||||
default_port = PORT
|
||||
#: AMT listens on 16992 without TLS and on 16993 with it; one of them answers.
|
||||
PORT_SPECS = [
|
||||
PortSpec("http", 16992, ("/",), weight=5.0),
|
||||
PortSpec("https", 16993, ("/",), weight=5.0),
|
||||
]
|
||||
#: AMT names itself in the ``Server`` header of every answer, and its web UI in the
|
||||
#: title ("Intel(R) Active Management Technology 11.8.50.3399").
|
||||
HTTP_FINGERPRINT = [FingerprintRule("active management technology", 10.0)]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: int = 60,
|
||||
optional_args: _JsonDict | None = None,
|
||||
) -> None:
|
||||
self.hostname = hostname
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
self.optional_args: _JsonDict = optional_args or {}
|
||||
self.transport = WsmanTransport(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
port=self.optional_args.get("port"),
|
||||
tls=self.optional_args.get("tls"),
|
||||
verify=self.optional_args.get("verify_ssl", True),
|
||||
timeout=timeout,
|
||||
retries=self.optional_args.get("retries", 1),
|
||||
)
|
||||
self._cache: _JsonDict = {}
|
||||
self._open = False
|
||||
|
||||
# --- connection ----------------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
"""Connect: read AMT's general settings, which every AMT has."""
|
||||
self._cache = {}
|
||||
try:
|
||||
self._cache["general"] = self.transport.get("AMT_GeneralSettings")
|
||||
except WsmanAuthError as exc:
|
||||
raise ConnectionException(f"{self.hostname}: AMT refused the credentials") from exc
|
||||
except WsmanError as exc:
|
||||
raise ConnectionException(f"Cannot connect to AMT at {self.hostname}: {exc}") from exc
|
||||
self._open = True
|
||||
|
||||
def close(self) -> None:
|
||||
self.transport.close()
|
||||
self._cache = {}
|
||||
self._open = False
|
||||
|
||||
def is_alive(self) -> _JsonDict:
|
||||
if not self._open:
|
||||
return {"is_alive": False}
|
||||
try:
|
||||
self.transport.get("AMT_GeneralSettings")
|
||||
except WsmanError:
|
||||
return {"is_alive": False}
|
||||
return {"is_alive": True}
|
||||
|
||||
# --- reads (cached per connection) ---------------------------------------------------
|
||||
|
||||
def _connected(self) -> WsmanTransport:
|
||||
if not self._open:
|
||||
raise ConnectionException(f"{self.hostname}: not connected, call open() first")
|
||||
return self.transport
|
||||
|
||||
def _cached(self, key: str, load: Callable[[], _T]) -> _T:
|
||||
self._connected()
|
||||
if key not in self._cache:
|
||||
self._cache[key] = load()
|
||||
return self._cache[key] # type: ignore[no-any-return]
|
||||
|
||||
def _get(self, resource: str) -> _JsonDict:
|
||||
return self._cached(resource, lambda: self.transport.get(resource))
|
||||
|
||||
def _all(self, resource: str) -> list[_JsonDict]:
|
||||
"""Every instance of ``resource``; none where this AMT does not know the class
|
||||
(older versions know fewer)."""
|
||||
|
||||
def load() -> list[_JsonDict]:
|
||||
try:
|
||||
return self.transport.enumerate(resource)
|
||||
except WsmanFault as exc:
|
||||
LOG.info("%s: no %s (%s)", self.hostname, resource, exc)
|
||||
return []
|
||||
|
||||
return self._cached(resource, load)
|
||||
|
||||
def _general(self) -> _JsonDict:
|
||||
return self._cached("general", lambda: self.transport.get("AMT_GeneralSettings"))
|
||||
|
||||
def _identity(self) -> dict[str, str]:
|
||||
return parse_system_identity(self._all("CIM_Chassis"), self._all("CIM_Card"))
|
||||
|
||||
def _software(self) -> dict[str, str]:
|
||||
return parse_software_identity(self._all("CIM_SoftwareIdentity"))
|
||||
|
||||
def _ports(self) -> list[_JsonDict]:
|
||||
return parse_ethernet_ports(self._all("AMT_EthernetPortSettings"))
|
||||
|
||||
def _power(self) -> _JsonDict:
|
||||
"""The power state, read fresh: it is what changes between two calls."""
|
||||
services = self._connected().enumerate("CIM_AssociatedPowerManagementService")
|
||||
return parse_power_state(services[0] if services else {})
|
||||
|
||||
# --- NAPALM getters ------------------------------------------------------------------
|
||||
|
||||
def get_facts(self) -> _JsonDict:
|
||||
general = self._general()
|
||||
identity = self._identity()
|
||||
name = text(general.get("HostName"))
|
||||
domain = text(general.get("DomainName"))
|
||||
return {
|
||||
"uptime": -1.0, # AMT keeps no uptime of the machine or of itself
|
||||
"vendor": identity["manufacturer"],
|
||||
"model": identity["model"],
|
||||
"hostname": name or self.hostname,
|
||||
"fqdn": f"{name}.{domain}" if name and domain else name or self.hostname,
|
||||
"os_version": self._software()["amt"],
|
||||
"serial_number": identity["serial_number"],
|
||||
"interface_list": [p["name"] for p in self._ports()],
|
||||
}
|
||||
|
||||
def get_interfaces(self) -> _JsonDict:
|
||||
enabled = is_true(self._general().get("NetworkInterfaceEnabled", "true"))
|
||||
interfaces = {}
|
||||
for port in self._ports():
|
||||
address = "DHCP" if port["dhcp"] else "static"
|
||||
shared = ", shares the host's IP address" if port["shared_ip"] else ""
|
||||
interfaces[port["name"]] = {
|
||||
"is_up": port["link_up"],
|
||||
"is_enabled": enabled,
|
||||
"description": f"Intel AMT, {port['name']}, {address}{shared}",
|
||||
"last_flapped": -1.0,
|
||||
"speed": -1.0,
|
||||
"mtu": -1,
|
||||
"mac_address": port["mac_address"],
|
||||
}
|
||||
return interfaces
|
||||
|
||||
def get_interfaces_ip(self) -> _JsonDict:
|
||||
return {
|
||||
p["name"]: {"ipv4": {p["ip"]: {"prefix_length": p["prefix_length"]}}}
|
||||
for p in self._ports()
|
||||
if p["ip"] and p["prefix_length"] is not None
|
||||
}
|
||||
|
||||
def get_environment(self) -> _JsonDict:
|
||||
"""Empty: AMT on a client platform reports no temperatures, fans or power supplies,
|
||||
and like any management controller it does not know the OS's load."""
|
||||
self._connected()
|
||||
return {"fans": {}, "temperature": {}, "power": {}, "cpu": {}, "memory": {}}
|
||||
|
||||
# --- role methods --------------------------------------------------------------------
|
||||
|
||||
def get_power_state(self) -> _JsonDict:
|
||||
power = self._power()
|
||||
return {
|
||||
"state": power["state"],
|
||||
"actions": accepted_actions(power["state"], power["available"]),
|
||||
}
|
||||
|
||||
def set_power_state(self, action: str) -> None:
|
||||
if action not in POWER_ACTIONS:
|
||||
raise ValueError(f"{action!r} is not one of {', '.join(POWER_ACTIONS)}")
|
||||
power = self._power()
|
||||
accepted = accepted_actions(power["state"], power["available"])
|
||||
if action not in accepted:
|
||||
raise RuntimeError(
|
||||
f"{self.hostname}: {action} not accepted while the machine is "
|
||||
f"{power['state'] or 'in an unknown state'} "
|
||||
f"(accepted: {', '.join(accepted) or 'none'})"
|
||||
)
|
||||
try:
|
||||
output = self.transport.invoke(
|
||||
"CIM_PowerManagementService",
|
||||
"RequestPowerStateChange",
|
||||
[("PowerState", POWER_REQUESTS[action])],
|
||||
raw=_MANAGED_SYSTEM,
|
||||
check=False,
|
||||
)
|
||||
except WsmanError as exc:
|
||||
raise RuntimeError(f"{self.hostname}: {action} refused: {exc}") from exc
|
||||
code = text(output.get("ReturnValue"))
|
||||
if code not in _POWER_ACCEPTED:
|
||||
raise RuntimeError(f"{self.hostname}: {action} refused by AMT (ReturnValue {code})")
|
||||
|
||||
def get_hardware_inventory(self) -> _JsonDict:
|
||||
identity = self._identity()
|
||||
packages = self._all("CIM_ComputerSystemPackage")
|
||||
return {
|
||||
"manufacturer": identity["manufacturer"],
|
||||
"model": identity["model"],
|
||||
"serial_number": identity["serial_number"],
|
||||
"system_uuid": parse_platform_guid(
|
||||
packages[0].get("PlatformGUID", "") if packages else ""
|
||||
),
|
||||
"cpus": parse_cpus(self._all("CIM_Processor"), self._all("CIM_Chip")),
|
||||
"memory": parse_memory(self._all("CIM_PhysicalMemory")),
|
||||
"drives": parse_drives(self._all("CIM_MediaAccessDevice")),
|
||||
"power_supplies": [],
|
||||
"nics": [
|
||||
{
|
||||
"name": p["name"],
|
||||
"mac_address": p["mac_address"],
|
||||
"speed_mbps": None,
|
||||
"link_up": p["link_up"],
|
||||
}
|
||||
for p in self._ports()
|
||||
],
|
||||
}
|
||||
|
||||
def get_event_log(self, limit: int = 500) -> list[_JsonDict]:
|
||||
"""AMT's hardware event log, newest first. ``id`` is the record's position in the
|
||||
log, oldest first: AMT numbers its records no other way."""
|
||||
start = self._connected().invoke("AMT_MessageLog", "PositionToFirstRecord", check=False)
|
||||
if text(start.get("ReturnValue")) == _NO_RECORD:
|
||||
return []
|
||||
if text(start.get("ReturnValue")) != "0":
|
||||
raise WsmanError(
|
||||
f"AMT_MessageLog.PositionToFirstRecord failed: ReturnValue "
|
||||
f"{text(start.get('ReturnValue'))}"
|
||||
)
|
||||
identifier = text(start.get("IterationIdentifier"))
|
||||
records: list[str] = []
|
||||
for _ in range(_MAX_RECORD_CALLS):
|
||||
page = self.transport.invoke(
|
||||
"AMT_MessageLog",
|
||||
"GetRecords",
|
||||
[("IterationIdentifier", identifier), ("MaxReadRecords", _RECORDS_PER_CALL)],
|
||||
)
|
||||
batch = page.get("RecordArray") or []
|
||||
records.extend(batch if isinstance(batch, list) else [batch])
|
||||
identifier = text(page.get("IterationIdentifier"))
|
||||
if is_true(page.get("NoMoreRecords")) or not batch:
|
||||
break
|
||||
entries = [
|
||||
e for e in (parse_event_record(r, str(n)) for n, r in enumerate(records, start=1)) if e
|
||||
]
|
||||
entries.sort(key=lambda e: e["timestamp"] or 0.0, reverse=True)
|
||||
return entries[:limit]
|
||||
|
||||
def get_firmware_inventory(self) -> list[_JsonDict]:
|
||||
return parse_firmware(self._software(), self._all("CIM_BIOSElement"))
|
||||
|
||||
def get_management_protocols(self) -> _JsonDict:
|
||||
def optional_get(resource: str) -> _JsonDict:
|
||||
try:
|
||||
return self._get(resource)
|
||||
except WsmanFault:
|
||||
return {}
|
||||
|
||||
return parse_management_protocols(
|
||||
tls_settings=self._all("AMT_TLSSettingData"),
|
||||
redirection=optional_get("AMT_RedirectionService"),
|
||||
kvm_settings=optional_get("IPS_KVMRedirectionSettingData"),
|
||||
kvm_saps=self._all("CIM_KVMRedirectionSAP"),
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Pure functions from WS-Management instances (dicts) to the shapes the driver returns."""
|
||||
|
||||
from napalm_amt.parsers.inventory import parse_cpus, parse_drives, parse_memory
|
||||
from napalm_amt.parsers.logs import parse_event_record
|
||||
from napalm_amt.parsers.network import parse_ethernet_ports
|
||||
from napalm_amt.parsers.power import POWER_REQUESTS, accepted_actions, parse_power_state
|
||||
from napalm_amt.parsers.protocols import parse_management_protocols
|
||||
from napalm_amt.parsers.system import (
|
||||
parse_firmware,
|
||||
parse_platform_guid,
|
||||
parse_software_identity,
|
||||
parse_system_identity,
|
||||
uuid_from_base64,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"POWER_REQUESTS",
|
||||
"accepted_actions",
|
||||
"parse_cpus",
|
||||
"parse_drives",
|
||||
"parse_ethernet_ports",
|
||||
"parse_event_record",
|
||||
"parse_firmware",
|
||||
"parse_management_protocols",
|
||||
"parse_memory",
|
||||
"parse_platform_guid",
|
||||
"parse_power_state",
|
||||
"parse_software_identity",
|
||||
"parse_system_identity",
|
||||
"uuid_from_base64",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Helpers shared by the parsers: WS-Management values are strings, lists or missing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
#: Values firmware puts where it has nothing to say (SMBIOS defaults), compared lowercased.
|
||||
PLACEHOLDERS = frozenset(
|
||||
{
|
||||
"",
|
||||
"0",
|
||||
"none",
|
||||
"default string",
|
||||
"not specified",
|
||||
"to be filled by o.e.m.",
|
||||
"system product name",
|
||||
"system serial number",
|
||||
"123456789",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def as_list(value: Any) -> list[Any]:
|
||||
"""A repeated element as a list; a single one as a list of one; none as empty."""
|
||||
if value is None or value == "":
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def first(value: Any) -> Any:
|
||||
"""The first of a value that may be repeated."""
|
||||
values = as_list(value)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def to_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(str(first(value)).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_true(value: Any) -> bool:
|
||||
return str(first(value)).strip().lower() in ("true", "1")
|
||||
|
||||
|
||||
def text(value: Any) -> str:
|
||||
"""A string value, stripped; a missing or nested one as empty."""
|
||||
value = first(value)
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def meaningful(value: Any) -> str:
|
||||
"""``text(value)``, or empty for a firmware placeholder."""
|
||||
result = text(value)
|
||||
return "" if result.lower() in PLACEHOLDERS else result
|
||||
@@ -0,0 +1,90 @@
|
||||
"""CPUs, DIMMs and drives of the managed machine, as AMT reads them from SMBIOS.
|
||||
|
||||
AMT knows less than a server BMC: no core counts, no drive models, no health per DIMM.
|
||||
What it does not know is ``None`` or empty, never guessed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_amt.parsers.common import text, to_int
|
||||
|
||||
_Instance = dict[str, Any]
|
||||
|
||||
#: CIM_Processor.CPUStatus -> role status.
|
||||
_CPU_STATUS = {1: "OK", 4: "OK", 2: "Warning", 3: "Critical"}
|
||||
#: CIM_PhysicalMemory.MemoryType, which AMT fills with the SMBIOS memory type.
|
||||
_MEMORY_TYPES = {
|
||||
18: "DDR",
|
||||
19: "DDR2",
|
||||
24: "DDR3",
|
||||
26: "DDR4",
|
||||
27: "LPDDR",
|
||||
28: "LPDDR2",
|
||||
29: "LPDDR3",
|
||||
30: "LPDDR4",
|
||||
34: "DDR5",
|
||||
35: "LPDDR5",
|
||||
}
|
||||
#: CIM OperationalStatus -> role status.
|
||||
_OPERATIONAL = {2: "OK", 3: "Warning", 4: "Warning", 5: "Warning", 6: "Critical", 7: "Critical"}
|
||||
|
||||
|
||||
def parse_cpus(processors: list[_Instance], chips: list[_Instance]) -> list[dict[str, Any]]:
|
||||
"""One entry per ``CIM_Processor``; the model is the ``CIM_Chip`` with its tag."""
|
||||
models = {text(c.get("Tag")): text(c.get("Version")) for c in chips}
|
||||
return [
|
||||
{
|
||||
"socket": text(p.get("DeviceID")),
|
||||
"model": models.get(text(p.get("DeviceID")), ""),
|
||||
"cores": None,
|
||||
"threads": None,
|
||||
"status": _CPU_STATUS.get(to_int(p.get("CPUStatus")) or 0, ""),
|
||||
}
|
||||
for p in processors
|
||||
]
|
||||
|
||||
|
||||
def _positive(value: Any) -> int | None:
|
||||
number = to_int(value)
|
||||
return number if number and number > 0 else None
|
||||
|
||||
|
||||
def parse_memory(modules: list[_Instance]) -> list[dict[str, Any]]:
|
||||
"""One entry per ``CIM_PhysicalMemory``. AMT reports no health per module."""
|
||||
return [
|
||||
{
|
||||
"slot": text(m.get("BankLabel")) or text(m.get("Tag")),
|
||||
"size_bytes": _positive(m.get("Capacity")),
|
||||
"type": _MEMORY_TYPES.get(to_int(m.get("MemoryType")) or 0, ""),
|
||||
"speed_mts": (
|
||||
_positive(m.get("ConfiguredMemoryClockSpeed"))
|
||||
or _positive(m.get("MaxMemorySpeed"))
|
||||
or _positive(m.get("Speed"))
|
||||
),
|
||||
"part_number": text(m.get("PartNumber")),
|
||||
"serial_number": text(m.get("SerialNumber")),
|
||||
"status": "",
|
||||
}
|
||||
for m in modules
|
||||
]
|
||||
|
||||
|
||||
def parse_drives(devices: list[_Instance]) -> list[dict[str, Any]]:
|
||||
"""One entry per ``CIM_MediaAccessDevice``; ``MaxMediaSize`` is in kilobytes of 1000."""
|
||||
drives = []
|
||||
for d in devices:
|
||||
size = _positive(d.get("MaxMediaSize"))
|
||||
drives.append(
|
||||
{
|
||||
"slot": text(d.get("DeviceID")),
|
||||
"model": "",
|
||||
"serial_number": "",
|
||||
"size_bytes": size * 1000 if size else None,
|
||||
"media": "",
|
||||
"protocol": "",
|
||||
"status": _OPERATIONAL.get(to_int(d.get("OperationalStatus")) or 0, ""),
|
||||
}
|
||||
)
|
||||
return drives
|
||||
@@ -0,0 +1,173 @@
|
||||
"""AMT's hardware event log: the base64 records of ``AMT_MessageLog.GetRecords``.
|
||||
|
||||
Each record is a 21-byte platform event (ASF/PET): a little-endian Unix timestamp, then
|
||||
device address, sensor type, event type, offset, source type, severity, sensor number,
|
||||
entity, entity instance and eight bytes of event data. The texts follow Intel's decoder in
|
||||
go-wsman-messages, which knows the events AMT itself writes: failed logins, BIOS progress
|
||||
and errors, watchdog and boot failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from typing import Any
|
||||
|
||||
_RECORD_LENGTH = 21
|
||||
|
||||
#: Entity codes (IPMI entity IDs) -> name.
|
||||
_ENTITIES = {
|
||||
0: "Unspecified",
|
||||
1: "Other",
|
||||
2: "Unknown",
|
||||
3: "Processor",
|
||||
4: "Disk",
|
||||
5: "Peripheral",
|
||||
6: "System management module",
|
||||
7: "System board",
|
||||
8: "Memory module",
|
||||
9: "Processor module",
|
||||
10: "Power supply",
|
||||
11: "Add in card",
|
||||
12: "Front panel board",
|
||||
13: "Back panel board",
|
||||
14: "Power system board",
|
||||
15: "Drive backplane",
|
||||
16: "System internal expansion board",
|
||||
17: "Other system board",
|
||||
18: "Processor board",
|
||||
19: "Power unit",
|
||||
20: "Power module",
|
||||
21: "Power management board",
|
||||
22: "Chassis back panel board",
|
||||
23: "System chassis",
|
||||
24: "Sub chassis",
|
||||
25: "Other chassis board",
|
||||
26: "Disk drive bay",
|
||||
27: "Peripheral bay",
|
||||
28: "Device bay",
|
||||
29: "Fan cooling",
|
||||
30: "Cooling unit",
|
||||
31: "Cable interconnect",
|
||||
32: "Memory device",
|
||||
33: "System management software",
|
||||
34: "BIOS",
|
||||
35: "Intel(r) ME",
|
||||
36: "System bus",
|
||||
37: "Group",
|
||||
38: "Intel(r) ME",
|
||||
39: "External environment",
|
||||
40: "Battery",
|
||||
41: "Processing blade",
|
||||
42: "Connectivity switch",
|
||||
43: "Processor/memory module",
|
||||
44: "I/O module",
|
||||
45: "Processor I/O module",
|
||||
46: "Management controller firmware",
|
||||
47: "IPMI channel",
|
||||
48: "PCI bus",
|
||||
49: "PCI express bus",
|
||||
50: "SCSI bus",
|
||||
51: "SATA/SAS bus",
|
||||
52: "Processor front side bus",
|
||||
}
|
||||
_FIRMWARE_ERRORS = {
|
||||
0: "Unspecified.",
|
||||
1: "No system memory is physically installed in the system.",
|
||||
2: "No usable system memory, all installed memory has experienced an unrecoverable failure.",
|
||||
3: "Unrecoverable hard-disk/ATAPI/IDE device failure.",
|
||||
4: "Unrecoverable system-board failure.",
|
||||
5: "Unrecoverable diskette subsystem failure.",
|
||||
6: "Unrecoverable hard-disk controller failure.",
|
||||
7: "Unrecoverable PS/2 or USB keyboard failure.",
|
||||
8: "Removable boot media not found.",
|
||||
9: "Unrecoverable video controller failure.",
|
||||
10: "No video device detected.",
|
||||
11: "Firmware (BIOS) ROM corruption detected.",
|
||||
12: "CPU voltage mismatch (processors that share same supply have mismatched voltage "
|
||||
"requirements)",
|
||||
13: "CPU speed matching failure",
|
||||
}
|
||||
_FIRMWARE_PROGRESS = {
|
||||
0: "Unspecified.",
|
||||
1: "Memory initialization.",
|
||||
2: "Starting hard-disk initialization and test",
|
||||
3: "Secondary processor(s) initialization",
|
||||
4: "User authentication",
|
||||
5: "User-initiated system setup",
|
||||
6: "USB resource configuration",
|
||||
7: "PCI resource configuration",
|
||||
8: "Option ROM initialization",
|
||||
9: "Video initialization",
|
||||
10: "Cache initialization",
|
||||
11: "SM Bus initialization",
|
||||
12: "Keyboard controller initialization",
|
||||
13: "Embedded controller/management controller initialization",
|
||||
14: "Docking station attachment",
|
||||
15: "Enabling docking station",
|
||||
16: "Docking station ejection",
|
||||
17: "Disabling docking station",
|
||||
18: "Calling operating system wake-up vector",
|
||||
19: "Starting operating system boot process",
|
||||
20: "Baseboard or motherboard initialization",
|
||||
21: "reserved",
|
||||
22: "Floppy initialization",
|
||||
23: "Keyboard test",
|
||||
24: "Pointing device test",
|
||||
25: "Primary processor initialization",
|
||||
}
|
||||
_WATCHDOG_STATES = {1: "Not Started", 2: "Stopped", 4: "Running", 8: "Expired", 16: "Suspended"}
|
||||
_FIXED = {
|
||||
30: "No bootable media",
|
||||
32: "Operating system lockup or power interrupt",
|
||||
35: "System boot failure",
|
||||
37: "System firmware started (at least one CPU is properly executing).",
|
||||
}
|
||||
|
||||
|
||||
def _severity(code: int) -> str:
|
||||
"""PET severity: 16 critical, 32 non-recoverable; 8 non-critical; the rest is fine."""
|
||||
if code >= 16:
|
||||
return "Critical"
|
||||
if code >= 8:
|
||||
return "Warning"
|
||||
return "OK"
|
||||
|
||||
|
||||
def _description(sensor_type: int, offset: int, data: bytes) -> str:
|
||||
if sensor_type == 6:
|
||||
return (
|
||||
f"Authentication failed {data[1] + (data[2] << 8)} times. "
|
||||
"The system may be under attack."
|
||||
)
|
||||
if sensor_type == 15:
|
||||
if data[0] == 235:
|
||||
return "Invalid Data"
|
||||
table = _FIRMWARE_ERRORS if offset == 0 else _FIRMWARE_PROGRESS
|
||||
return table.get(data[1], f"code {data[1]}")
|
||||
if sensor_type == 18:
|
||||
if data[0] == 170:
|
||||
agent = f"{data[4]:x}{data[3]:x}{data[2]:x}{data[1]:x}-{data[6]:x}{data[5]:x}"
|
||||
state = _WATCHDOG_STATES.get(data[7], str(data[7]))
|
||||
return f"Agent watchdog {agent}-... changed to {state}"
|
||||
return "Unknown event data field"
|
||||
return _FIXED.get(sensor_type, f"sensor type {sensor_type}")
|
||||
|
||||
|
||||
def parse_event_record(record: str, entry_id: str) -> dict[str, Any] | None:
|
||||
"""One base64 record as a role event log entry; None for a record that is not one."""
|
||||
try:
|
||||
raw = base64.b64decode(record, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
if len(raw) < _RECORD_LENGTH:
|
||||
return None
|
||||
timestamp = int.from_bytes(raw[0:4], "little")
|
||||
sensor_type, offset, severity, entity = raw[5], raw[7], raw[9], raw[11]
|
||||
entity_name = _ENTITIES.get(entity, f"Entity {entity}")
|
||||
return {
|
||||
"id": entry_id,
|
||||
"timestamp": float(timestamp) if timestamp else None,
|
||||
"severity": _severity(severity),
|
||||
"message": f"{entity_name}: {_description(sensor_type, offset, raw[13:21])}",
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""AMT's own network ports (``AMT_EthernetPortSettings``).
|
||||
|
||||
AMT shares the machine's network controller: the wired port has the host's MAC address
|
||||
and, in DHCP mode, the host's IP address too. A port without a MAC address is a wireless
|
||||
port on a machine without a WLAN card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
from napalm_amt.parsers.common import is_true, text
|
||||
|
||||
_Instance = dict[str, Any]
|
||||
_WIRELESS = "3" # PhysicalConnectionType: 0 integrated LAN, 1 discrete LAN, 2 dock, 3 WLAN
|
||||
|
||||
|
||||
def _mac(value: str) -> str:
|
||||
mac = value.replace("-", ":").lower()
|
||||
return "" if not mac.replace(":", "").strip("0") else mac
|
||||
|
||||
|
||||
def _prefix(mask: str) -> int | None:
|
||||
if not mask:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.IPv4Network(f"0.0.0.0/{mask}").prefixlen
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_ethernet_ports(ports: list[_Instance]) -> list[dict[str, Any]]:
|
||||
"""The ports in use: name (``wired``/``wireless``), MAC, link, DHCP, whether AMT shares
|
||||
the host's IP address, and its own address where AMT reports one."""
|
||||
parsed: list[dict[str, Any]] = []
|
||||
for port in ports:
|
||||
mac = _mac(text(port.get("MACAddress")))
|
||||
if not mac:
|
||||
continue
|
||||
name = "wireless" if text(port.get("PhysicalConnectionType")) == _WIRELESS else "wired"
|
||||
if any(p["name"] == name for p in parsed):
|
||||
name = f"{name}{len(parsed)}"
|
||||
dhcp = is_true(port.get("DHCPEnabled"))
|
||||
ip = text(port.get("IPAddress"))
|
||||
parsed.append(
|
||||
{
|
||||
"name": name,
|
||||
"mac_address": mac,
|
||||
"link_up": is_true(port.get("LinkIsUp")),
|
||||
"dhcp": dhcp,
|
||||
"shared_ip": is_true(port.get("SharedDynamicIP" if dhcp else "SharedStaticIp")),
|
||||
"ip": ip,
|
||||
"prefix_length": _prefix(text(port.get("SubnetMask"))) if ip else None,
|
||||
}
|
||||
)
|
||||
return parsed
|
||||
@@ -0,0 +1,55 @@
|
||||
"""The machine's power state and the power actions AMT accepts.
|
||||
|
||||
``CIM_AssociatedPowerManagementService.PowerState`` uses the CIM power state codes, and
|
||||
``AvailableRequestedPowerStates`` lists the codes AMT accepts now. AMT lists the graceful
|
||||
ones (12, 14) only where the host can honour them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types.models import POWER_ACTIONS
|
||||
|
||||
from napalm_amt.parsers.common import as_list, to_int
|
||||
|
||||
_Instance = dict[str, Any]
|
||||
|
||||
#: Role power action -> the CIM power state AMT is asked for. 8 is "Power Off - Hard" and
|
||||
#: 10 "Master Bus Reset" (a hard reset); Intel verified both, and 2 and 5, on AMT.
|
||||
POWER_REQUESTS: dict[str, int] = {
|
||||
"On": 2,
|
||||
"GracefulShutdown": 12,
|
||||
"ForceOff": 8,
|
||||
"GracefulRestart": 14,
|
||||
"ForceRestart": 10,
|
||||
"PowerCycle": 5,
|
||||
}
|
||||
_ON = {2}
|
||||
#: Off, but also asleep (3, 4) and hibernating (7): the OS is not running, and On wakes it.
|
||||
_OFF = {3, 4, 6, 7, 8, 12, 13}
|
||||
|
||||
|
||||
def parse_power_state(service: _Instance) -> dict[str, Any]:
|
||||
"""``{"state": "On" | "Off" | "", "available": [CIM power state codes]}``."""
|
||||
code = to_int(service.get("PowerState"))
|
||||
state = "On" if code in _ON else "Off" if code in _OFF else ""
|
||||
available = [
|
||||
n
|
||||
for n in (to_int(v) for v in as_list(service.get("AvailableRequestedPowerStates")))
|
||||
if n is not None
|
||||
]
|
||||
return {"state": state, "available": available}
|
||||
|
||||
|
||||
def accepted_actions(state: str, available: list[int]) -> list[str]:
|
||||
"""The role actions that make sense in ``state`` and that AMT offers.
|
||||
|
||||
A machine that is off accepts On (AMT may leave 2 out of its list then). A machine that
|
||||
is on accepts every other action whose state AMT lists.
|
||||
"""
|
||||
if state == "Off":
|
||||
return ["On"] if not available or POWER_REQUESTS["On"] in available else []
|
||||
if state == "On":
|
||||
return [a for a in POWER_ACTIONS if a != "On" and POWER_REQUESTS[a] in available]
|
||||
return []
|
||||
@@ -0,0 +1,61 @@
|
||||
"""What AMT listens on, in the role's ``get_management_protocols`` shape.
|
||||
|
||||
AMT has no IPMI, SSH or SNMP. It answers WS-Management and its web UI on 16992 (plain
|
||||
HTTP, Digest auth) until TLS is set up, then on 16993; with TLS, plain connections from the
|
||||
network are refused unless ``AcceptNonSecureConnections`` says otherwise. The KVM console,
|
||||
serial-over-LAN and IDE/USB redirection (virtual media) go over the redirection port 16994,
|
||||
or 16995 with TLS; KVM can also listen on the standard VNC port 5900.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_amt.parsers.common import is_true, text, to_int
|
||||
from napalm_amt.wsman import PORT, TLS_PORT
|
||||
|
||||
_Instance = dict[str, Any]
|
||||
|
||||
_REDIRECTION_PORT = 16994
|
||||
_REDIRECTION_TLS_PORT = 16995
|
||||
_VNC_PORT = 5900
|
||||
#: CIM_KVMRedirectionSAP.EnabledState: 2 enabled, 6 enabled but offline (no session yet).
|
||||
_KVM_ENABLED = {2, 6}
|
||||
#: AMT_RedirectionService.EnabledState: 32768 none, 32769 IDE-R, 32770 SOL, 32771 both.
|
||||
_IDER_ENABLED = {32769, 32771}
|
||||
|
||||
|
||||
def _network_tls(tls_settings: list[_Instance]) -> _Instance:
|
||||
"""The TLS setting for the network ("802.3"); the LMS one only covers local access."""
|
||||
return next((t for t in tls_settings if "802.3" in text(t.get("InstanceID"))), {})
|
||||
|
||||
|
||||
def parse_management_protocols(
|
||||
tls_settings: list[_Instance],
|
||||
redirection: _Instance,
|
||||
kvm_settings: _Instance,
|
||||
kvm_saps: list[_Instance],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
network_tls = _network_tls(tls_settings)
|
||||
tls = is_true(network_tls.get("Enabled"))
|
||||
plain = not tls or is_true(network_tls.get("AcceptNonSecureConnections"))
|
||||
redirection_port = _REDIRECTION_TLS_PORT if tls else _REDIRECTION_PORT
|
||||
|
||||
kvm_enabled = text(kvm_settings.get("EnabledByMEBx")).lower() != "false" and any(
|
||||
to_int(sap.get("EnabledState")) in _KVM_ENABLED for sap in kvm_saps
|
||||
)
|
||||
vnc = is_true(kvm_settings.get("Is5900PortEnabled"))
|
||||
ider = (
|
||||
to_int(redirection.get("EnabledState")) in _IDER_ENABLED
|
||||
and text(redirection.get("ListenerEnabled")).lower() != "false"
|
||||
)
|
||||
|
||||
return {
|
||||
"ipmi": {"enabled": False, "port": 623},
|
||||
"ssh": {"enabled": False, "port": 22},
|
||||
"http": {"enabled": plain, "port": PORT},
|
||||
"https": {"enabled": tls, "port": TLS_PORT},
|
||||
"snmp": {"enabled": False, "port": 161},
|
||||
"kvm": {"enabled": kvm_enabled, "port": _VNC_PORT if vnc else redirection_port},
|
||||
"virtual_media": {"enabled": ider, "port": redirection_port},
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""The managed machine's identity and AMT's firmware.
|
||||
|
||||
``CIM_Chassis`` carries the system's SMBIOS identity (manufacturer, product, serial), and
|
||||
``CIM_Card`` the baseboard's. ``CIM_ComputerSystemPackage.PlatformGUID`` is the SMBIOS
|
||||
system UUID, the same that the operating system on the machine reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from napalm_amt.parsers.common import meaningful, text
|
||||
|
||||
_Instance = dict[str, Any]
|
||||
_HEX32 = re.compile(r"^[0-9a-fA-F]{32}$")
|
||||
|
||||
|
||||
def _uuid_from_hex(raw: str) -> str:
|
||||
"""The SMBIOS UUID from its 16 bytes as stored: the first three groups little-endian,
|
||||
as dmidecode and Intel's console decode them. Empty for all-zero or all-FF bytes, which
|
||||
SMBIOS defines as "not present" and "not set"."""
|
||||
if not _HEX32.match(raw or ""):
|
||||
return ""
|
||||
raw = raw.lower()
|
||||
if raw in ("0" * 32, "f" * 32):
|
||||
return ""
|
||||
b = bytes.fromhex(raw)
|
||||
swapped = b[3::-1] + b[5:3:-1] + b[7:5:-1] + b[8:]
|
||||
h = swapped.hex()
|
||||
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
|
||||
|
||||
|
||||
def parse_platform_guid(guid: str) -> str:
|
||||
"""``CIM_ComputerSystemPackage.PlatformGUID`` (32 hex digits) as the SMBIOS UUID."""
|
||||
return _uuid_from_hex(text(guid))
|
||||
|
||||
|
||||
def uuid_from_base64(value: str) -> str:
|
||||
"""``AMT_SetupAndConfigurationService.GetUuid``'s base64 UUID as the SMBIOS UUID."""
|
||||
try:
|
||||
raw = base64.b64decode(text(value), validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
return ""
|
||||
return _uuid_from_hex(raw.hex())
|
||||
|
||||
|
||||
def parse_software_identity(identities: list[_Instance]) -> dict[str, str]:
|
||||
"""AMT's version with its build number (``12.0.67.1579``, as AMT's ``Server`` header
|
||||
and Intel's advisories write it) and the ME firmware (``Flash``) version."""
|
||||
versions = {text(i.get("InstanceID")): text(i.get("VersionString")) for i in identities}
|
||||
amt = versions.get("AMT", "")
|
||||
build = versions.get("Build Number", "")
|
||||
return {
|
||||
"amt": f"{amt}.{build}" if amt and build else amt,
|
||||
"flash": versions.get("Flash", ""),
|
||||
}
|
||||
|
||||
|
||||
def parse_system_identity(chassis: list[_Instance], cards: list[_Instance]) -> dict[str, str]:
|
||||
"""Manufacturer, model and serial of the machine, from the chassis and, where it holds
|
||||
only placeholders, the baseboard; plus the baseboard's own model and serial."""
|
||||
system = chassis[0] if chassis else {}
|
||||
board = cards[0] if cards else {}
|
||||
|
||||
def pick(key: str) -> str:
|
||||
return meaningful(system.get(key)) or meaningful(board.get(key))
|
||||
|
||||
return {
|
||||
"manufacturer": pick("Manufacturer"),
|
||||
"model": pick("Model"),
|
||||
"serial_number": pick("SerialNumber"),
|
||||
"board_model": meaningful(board.get("Model")),
|
||||
"board_serial": meaningful(board.get("SerialNumber")),
|
||||
}
|
||||
|
||||
|
||||
def parse_firmware(identity: dict[str, str], bios: list[_Instance]) -> list[dict[str, Any]]:
|
||||
"""AMT and the BIOS. Neither is updated through AMT, so ``updateable`` is unknown."""
|
||||
primary = next(
|
||||
(b for b in bios if text(b.get("PrimaryBIOS")).lower() == "true"), bios[0] if bios else {}
|
||||
)
|
||||
firmware = []
|
||||
if identity.get("amt"):
|
||||
firmware.append({"name": "Intel AMT", "version": identity["amt"], "updateable": None})
|
||||
if text(primary.get("Version")):
|
||||
firmware.append(
|
||||
{"name": "BIOS", "version": text(primary.get("Version")), "updateable": None}
|
||||
)
|
||||
return firmware
|
||||
Whitespace-only changes.
@@ -0,0 +1,158 @@
|
||||
"""Record a real AMT's answers as test fixtures: ``python -m napalm_amt.record HOST``.
|
||||
|
||||
Makes every read the driver makes and writes each raw answer under the name the tests'
|
||||
mock serves it by (``<Class>.get.xml``, ``<Class>.pull.xml``, ``<Class>.<Method>.xml``),
|
||||
plus a ``COMMANDS.txt`` and the unauthenticated landing page with its headers (what
|
||||
discovery sees). It never switches power and never writes to AMT.
|
||||
|
||||
The password comes from ``--password-file`` (a file on the host, the way netOrk keeps
|
||||
secrets) or the ``AMT_PASSWORD`` environment variable, never from the command line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from napalm_amt.wsman import PORT, WsmanError, WsmanTransport
|
||||
|
||||
#: Everything the driver reads: (class, operation). Methods with input are handled below.
|
||||
READS: list[tuple[str, str]] = [
|
||||
("AMT_GeneralSettings", "Get"),
|
||||
("AMT_SetupAndConfigurationService", "Get"),
|
||||
("AMT_RedirectionService", "Get"),
|
||||
("IPS_KVMRedirectionSettingData", "Get"),
|
||||
("AMT_EthernetPortSettings", "Enumerate"),
|
||||
("AMT_TLSSettingData", "Enumerate"),
|
||||
("CIM_KVMRedirectionSAP", "Enumerate"),
|
||||
("CIM_SoftwareIdentity", "Enumerate"),
|
||||
("CIM_BIOSElement", "Enumerate"),
|
||||
("CIM_Chassis", "Enumerate"),
|
||||
("CIM_Card", "Enumerate"),
|
||||
("CIM_ComputerSystemPackage", "Enumerate"),
|
||||
("CIM_Processor", "Enumerate"),
|
||||
("CIM_Chip", "Enumerate"),
|
||||
("CIM_PhysicalMemory", "Enumerate"),
|
||||
("CIM_MediaAccessDevice", "Enumerate"),
|
||||
("CIM_AssociatedPowerManagementService", "Enumerate"),
|
||||
# Not read by the driver yet; recorded to see what this AMT knows.
|
||||
("CIM_Fan", "Enumerate"),
|
||||
("CIM_Sensor", "Enumerate"),
|
||||
("AMT_SetupAndConfigurationService", "GetUuid"),
|
||||
]
|
||||
#: Values that are secrets, or close to one, replaced before anything is written.
|
||||
_REDACT = re.compile(r"(<(\w+:)?(ProvisioningServerOTP|RFBPassword)>)[^<]+(</)")
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self, out: Path) -> None:
|
||||
self.out = out
|
||||
self.pages: dict[str, int] = {}
|
||||
self.commands: list[str] = []
|
||||
|
||||
def __call__(self, resource: str, operation: str, content: bytes) -> None:
|
||||
if operation == "Enumerate" and b"Fault" not in content:
|
||||
return # the context only; the instances come with Pull
|
||||
suffix = operation.lower() if operation in ("Get", "Pull", "Enumerate") else operation
|
||||
key = f"{resource}.{suffix}"
|
||||
self.pages[key] = self.pages.get(key, 0) + 1
|
||||
name = f"{key}.xml" if self.pages[key] == 1 else f"{key}.{self.pages[key]}.xml"
|
||||
text = _REDACT.sub(r"\1REDACTED\4", content.decode("utf-8", errors="replace"))
|
||||
(self.out / name).write_text(text)
|
||||
self.commands.append(f"{name:<55} {operation} {resource}")
|
||||
|
||||
|
||||
def record(transport: WsmanTransport, out: Path) -> list[str]:
|
||||
"""Make every read in ``READS`` plus the event log; return the files written."""
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
recorder = _Recorder(out)
|
||||
transport.recorder = recorder
|
||||
try:
|
||||
for resource, operation in READS:
|
||||
try:
|
||||
if operation == "Get":
|
||||
transport.get(resource)
|
||||
elif operation == "Enumerate":
|
||||
transport.enumerate(resource)
|
||||
else:
|
||||
transport.invoke(resource, operation, check=False)
|
||||
except WsmanError as exc:
|
||||
print(f"{resource} {operation}: {exc}", file=sys.stderr)
|
||||
start = transport.invoke("AMT_MessageLog", "PositionToFirstRecord", check=False)
|
||||
if start.get("ReturnValue") == "0":
|
||||
transport.invoke(
|
||||
"AMT_MessageLog",
|
||||
"GetRecords",
|
||||
[
|
||||
("IterationIdentifier", start.get("IterationIdentifier", "1")),
|
||||
("MaxReadRecords", 390),
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
finally:
|
||||
transport.recorder = None
|
||||
(out / "COMMANDS.txt").write_text(
|
||||
"# Recorded with python -m napalm_amt.record\n" + "\n".join(recorder.commands) + "\n"
|
||||
)
|
||||
return [c.split()[0] for c in recorder.commands]
|
||||
|
||||
|
||||
def _landing_page(host: str, port: int, tls: bool, out: Path) -> None:
|
||||
"""The page discovery fetches, without credentials, with its status line and headers."""
|
||||
url = f"{'https' if tls else 'http'}://{host}:{port}/"
|
||||
try:
|
||||
response = requests.get(url, timeout=10, verify=False, allow_redirects=True)
|
||||
except requests.RequestException as exc:
|
||||
print(f"landing page: {exc}", file=sys.stderr)
|
||||
return
|
||||
headers = "\n".join(f"{k}: {v}" for k, v in response.headers.items())
|
||||
(out / "landing_page.txt").write_text(
|
||||
f"GET {url} -> {response.status_code}\n{headers}\n\n{response.text}"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m napalm_amt.record",
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("host")
|
||||
parser.add_argument("--user", default="admin")
|
||||
parser.add_argument("--password-file", type=Path)
|
||||
parser.add_argument("--port", type=int, default=PORT)
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--no-landing-page", action="store_true")
|
||||
parser.add_argument(
|
||||
"--insecure", action="store_true", help="do not verify AMT's TLS certificate (port 16993)"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.password_file:
|
||||
password = args.password_file.read_text().strip()
|
||||
elif os.environ.get("AMT_PASSWORD"):
|
||||
password = os.environ["AMT_PASSWORD"]
|
||||
else:
|
||||
parser.error("give --password-file or set AMT_PASSWORD")
|
||||
|
||||
transport = WsmanTransport(
|
||||
args.host, args.user, password, port=args.port, verify=not args.insecure
|
||||
)
|
||||
try:
|
||||
written = record(transport, args.out)
|
||||
finally:
|
||||
transport.close()
|
||||
if not args.no_landing_page:
|
||||
_landing_page(args.host, args.port, transport.tls, args.out)
|
||||
print(f"{len(written)} files in {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,284 @@
|
||||
"""A lean WS-Management client for Intel AMT.
|
||||
|
||||
AMT speaks WS-Management (DMTF DSP0226) with the CIM binding: SOAP 1.2 envelopes POSTed to
|
||||
``/wsman``, on port 16992 over plain HTTP or 16993 over TLS, authenticated with HTTP Digest.
|
||||
This client covers the four operations a driver needs: ``Get`` one instance, ``Enumerate``
|
||||
plus ``Pull`` all instances of a class, and invoke a method. Instances come back as plain
|
||||
dicts keyed by local element names; a repeated element becomes a list, an element with
|
||||
children a nested dict, and every value a string, as on the wire.
|
||||
|
||||
The envelopes follow Intel's own go-wsman-messages, which every AMT since version 6 accepts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from typing import Any
|
||||
from xml.sax.saxutils import escape, quoteattr
|
||||
|
||||
import requests
|
||||
from requests.auth import HTTPDigestAuth
|
||||
|
||||
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
|
||||
ADDRESSING_NS = "http://schemas.xmlsoap.org/ws/2004/08/addressing"
|
||||
WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"
|
||||
ENUMERATION_NS = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"
|
||||
TRANSFER_NS = "http://schemas.xmlsoap.org/ws/2004/09/transfer"
|
||||
ANONYMOUS = ADDRESSING_NS + "/role/anonymous"
|
||||
|
||||
PORT = 16992
|
||||
TLS_PORT = 16993
|
||||
|
||||
#: Class name prefix -> the schema its resource URI lives under.
|
||||
_SCHEMAS = {
|
||||
"AMT_": "http://intel.com/wbem/wscim/1/amt-schema/1/",
|
||||
"IPS_": "http://intel.com/wbem/wscim/1/ips-schema/1/",
|
||||
"CIM_": "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/",
|
||||
}
|
||||
#: Pages an enumeration may take. AMT's longest lists are a few dozen instances, and it
|
||||
#: returns all of them in one page when asked for 999; this only stops a runaway loop.
|
||||
_MAX_PAGES = 100
|
||||
_HEADERS = {"Content-Type": "application/soap+xml;charset=UTF-8"}
|
||||
|
||||
Instance = dict[str, Any]
|
||||
|
||||
|
||||
class WsmanError(Exception):
|
||||
"""AMT did not answer, or answered with something other than the expected response."""
|
||||
|
||||
|
||||
class WsmanAuthError(WsmanError):
|
||||
"""AMT refused the credentials (HTTP 401 after the Digest exchange)."""
|
||||
|
||||
|
||||
class WsmanFault(WsmanError):
|
||||
"""AMT answered with a SOAP fault."""
|
||||
|
||||
def __init__(self, code: str, subcode: str, reason: str, detail: str) -> None:
|
||||
self.code = code
|
||||
self.subcode = subcode
|
||||
self.reason = reason
|
||||
self.detail = detail
|
||||
super().__init__(f"{subcode or code}: {reason}" + (f" ({detail})" if detail else ""))
|
||||
|
||||
|
||||
def resource_uri(resource: str) -> str:
|
||||
"""The resource URI of a class, from its prefix (``AMT_``, ``IPS_``, ``CIM_``)."""
|
||||
for prefix, base in _SCHEMAS.items():
|
||||
if resource.startswith(prefix):
|
||||
return base + resource
|
||||
raise ValueError(f"{resource!r} is not an AMT, IPS or CIM class")
|
||||
|
||||
|
||||
def _local(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def _value(element: ET.Element) -> str | Instance:
|
||||
"""An element as a string (no children) or a dict of its children."""
|
||||
children = list(element)
|
||||
if not children:
|
||||
return (element.text or "").strip()
|
||||
result: Instance = {}
|
||||
for child in children:
|
||||
key, value = _local(child.tag), _value(child)
|
||||
if key not in result:
|
||||
result[key] = value
|
||||
elif isinstance(result[key], list):
|
||||
result[key].append(value)
|
||||
else:
|
||||
result[key] = [result[key], value]
|
||||
return result
|
||||
|
||||
|
||||
def to_instance(element: ET.Element | None) -> Instance:
|
||||
"""An instance element as a dict; an empty or missing one as an empty dict."""
|
||||
if element is None:
|
||||
return {}
|
||||
value = _value(element)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _fault(element: ET.Element) -> WsmanFault:
|
||||
def text(path: str) -> str:
|
||||
found = element.find(path)
|
||||
return "".join(found.itertext()).strip() if found is not None else ""
|
||||
|
||||
def unprefixed(qname: str) -> str:
|
||||
return qname.split(":", 1)[-1]
|
||||
|
||||
s = f"{{{SOAP_NS}}}"
|
||||
return WsmanFault(
|
||||
code=unprefixed(text(f"{s}Code/{s}Value")),
|
||||
subcode=unprefixed(text(f"{s}Code/{s}Subcode/{s}Value")),
|
||||
reason=text(f"{s}Reason/{s}Text"),
|
||||
detail=text(f"{s}Detail"),
|
||||
)
|
||||
|
||||
|
||||
class WsmanTransport:
|
||||
"""WS-Management over HTTP(S) with Digest auth, to one AMT.
|
||||
|
||||
``tls`` defaults to on for port 16993 and off otherwise. ``verify`` is passed to
|
||||
``requests``: ``True`` checks the certificate against its CA bundle, a path against
|
||||
that CA file, ``False`` not at all. ``retries`` counts extra attempts after a
|
||||
connection error; a response, even an error, is never retried. ``recorder``, when set,
|
||||
is called with the class, the operation and the raw body of every answer (to record
|
||||
fixtures).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
port: int | None = None,
|
||||
tls: bool | None = None,
|
||||
verify: bool | str = True,
|
||||
timeout: float = 60,
|
||||
retries: int = 1,
|
||||
) -> None:
|
||||
self.port = int(port or PORT)
|
||||
self.tls = self.port == TLS_PORT if tls is None else bool(tls)
|
||||
self.url = f"{'https' if self.tls else 'http'}://{host}:{self.port}/wsman"
|
||||
self.timeout = timeout
|
||||
self.retries = max(0, int(retries))
|
||||
self.session = requests.Session()
|
||||
self.session.auth = HTTPDigestAuth(username, password)
|
||||
self.session.verify = verify
|
||||
self._message_ids = itertools.count(1)
|
||||
self.recorder: Callable[[str, str, bytes], None] | None = None
|
||||
|
||||
def close(self) -> None:
|
||||
self.session.close()
|
||||
|
||||
# --- operations ----------------------------------------------------------------------
|
||||
|
||||
def get(self, resource: str, selectors: Mapping[str, str] | None = None) -> Instance:
|
||||
"""The one instance of ``resource`` (or the one ``selectors`` pick)."""
|
||||
body = self._post(resource, f"{TRANSFER_NS}/Get", "", selectors)
|
||||
return to_instance(next(iter(body), None))
|
||||
|
||||
def enumerate(self, resource: str) -> list[Instance]:
|
||||
"""Every instance of ``resource``: ``Enumerate``, then ``Pull`` to the end."""
|
||||
e = f"{{{ENUMERATION_NS}}}"
|
||||
body = self._post(
|
||||
resource, f"{ENUMERATION_NS}/Enumerate", f'<Enumerate xmlns="{ENUMERATION_NS}"/>'
|
||||
)
|
||||
context = body.findtext(f"{e}EnumerateResponse/{e}EnumerationContext")
|
||||
instances: list[Instance] = []
|
||||
for _ in range(_MAX_PAGES):
|
||||
if not context:
|
||||
return instances
|
||||
body = self._post(
|
||||
resource,
|
||||
f"{ENUMERATION_NS}/Pull",
|
||||
f'<Pull xmlns="{ENUMERATION_NS}"><EnumerationContext>{escape(context)}'
|
||||
f"</EnumerationContext><MaxElements>999</MaxElements>"
|
||||
f"<MaxCharacters>99999</MaxCharacters></Pull>",
|
||||
)
|
||||
page = body.find(f"{e}PullResponse")
|
||||
if page is None:
|
||||
raise WsmanError(f"{resource}: Pull answered without a PullResponse")
|
||||
instances.extend(to_instance(item) for item in page.findall(f"{e}Items/*"))
|
||||
if page.find(f"{e}EndOfSequence") is not None:
|
||||
return instances
|
||||
context = page.findtext(f"{e}EnumerationContext")
|
||||
raise WsmanError(f"{resource}: no end of the enumeration after {_MAX_PAGES} pages")
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
resource: str,
|
||||
method: str,
|
||||
params: Iterable[tuple[str, Any]] = (),
|
||||
raw: str = "",
|
||||
selectors: Mapping[str, str] | None = None,
|
||||
check: bool = True,
|
||||
) -> Instance:
|
||||
"""Call ``method`` of ``resource`` and return its output parameters.
|
||||
|
||||
``params`` are sent in order as ``<h:Name>value</h:Name>``; ``raw`` is appended
|
||||
unescaped after them, for parameters that are references (``h:`` is the class's
|
||||
namespace). With ``check``, a ``ReturnValue`` other than 0 raises.
|
||||
"""
|
||||
uri = resource_uri(resource)
|
||||
inner = "".join(f"<h:{name}>{escape(str(value))}</h:{name}>" for name, value in params)
|
||||
body = self._post(
|
||||
resource,
|
||||
f"{uri}/{method}",
|
||||
f'<h:{method}_INPUT xmlns:h="{uri}">{inner}{raw}</h:{method}_INPUT>',
|
||||
selectors,
|
||||
)
|
||||
output = to_instance(body.find(f"{{{uri}}}{method}_OUTPUT"))
|
||||
code = output.get("ReturnValue")
|
||||
if check and code not in (None, "0"):
|
||||
raise WsmanError(f"{resource}.{method} failed: ReturnValue {code}")
|
||||
return output
|
||||
|
||||
# --- plumbing ------------------------------------------------------------------------
|
||||
|
||||
def _envelope(
|
||||
self, uri: str, action: str, body: str, selectors: Mapping[str, str] | None
|
||||
) -> str:
|
||||
selector_set = ""
|
||||
if selectors:
|
||||
selector_set = (
|
||||
"<w:SelectorSet>"
|
||||
+ "".join(
|
||||
f"<w:Selector Name={quoteattr(name)}>{escape(value)}</w:Selector>"
|
||||
for name, value in selectors.items()
|
||||
)
|
||||
+ "</w:SelectorSet>"
|
||||
)
|
||||
return (
|
||||
f'<?xml version="1.0" encoding="utf-8"?>'
|
||||
f'<Envelope xmlns="{SOAP_NS}" xmlns:a="{ADDRESSING_NS}" xmlns:w="{WSMAN_NS}">'
|
||||
f"<Header><a:Action>{action}</a:Action><a:To>/wsman</a:To>"
|
||||
f"<w:ResourceURI>{uri}</w:ResourceURI>"
|
||||
f"<a:MessageID>{next(self._message_ids)}</a:MessageID>"
|
||||
f"<a:ReplyTo><a:Address>{ANONYMOUS}</a:Address></a:ReplyTo>"
|
||||
f"<w:OperationTimeout>PT60S</w:OperationTimeout>{selector_set}</Header>"
|
||||
f"<Body>{body}</Body></Envelope>"
|
||||
)
|
||||
|
||||
def _post(
|
||||
self, resource: str, action: str, body: str, selectors: Mapping[str, str] | None = None
|
||||
) -> ET.Element:
|
||||
envelope = self._envelope(resource_uri(resource), action, body, selectors)
|
||||
response = self._send(envelope.encode())
|
||||
if self.recorder is not None:
|
||||
self.recorder(resource, action.rsplit("/", 1)[-1], response.content)
|
||||
return self._body(response)
|
||||
|
||||
def _send(self, data: bytes) -> requests.Response:
|
||||
error: requests.RequestException | None = None
|
||||
for _ in range(self.retries + 1):
|
||||
try:
|
||||
return self.session.post(
|
||||
self.url, data=data, headers=_HEADERS, timeout=self.timeout
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
error = exc
|
||||
raise WsmanError(f"{self.url}: {error}") from error
|
||||
|
||||
def _body(self, response: requests.Response) -> ET.Element:
|
||||
"""The SOAP body of a response; a fault, a refusal or anything else raises."""
|
||||
if response.status_code == 401:
|
||||
raise WsmanAuthError(f"{self.url}: credentials refused (HTTP 401)")
|
||||
body = None
|
||||
# A DTD has no business in a WS-Management response; refusing one keeps entity
|
||||
# expansion out of the parser.
|
||||
if b"<!DOCTYPE" not in response.content:
|
||||
try:
|
||||
body = ET.fromstring(response.content).find(f"{{{SOAP_NS}}}Body")
|
||||
except ET.ParseError:
|
||||
body = None
|
||||
if body is not None:
|
||||
fault = body.find(f"{{{SOAP_NS}}}Fault")
|
||||
if fault is not None:
|
||||
raise _fault(fault)
|
||||
if body is None or response.status_code >= 400:
|
||||
raise WsmanError(f"{self.url}: HTTP {response.status_code}, no WS-Management answer")
|
||||
return body
|
||||
Reference in new issue
Block a user