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
Whitespace-only changes.
@@ -0,0 +1,100 @@
|
||||
"""CPUs, DIMMs and drives of the managed machine, from the recorded NUC9."""
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_amt.parsers import parse_cpus, parse_drives, parse_memory
|
||||
|
||||
from ..conftest import instances
|
||||
|
||||
|
||||
def test_cpus_take_the_model_from_the_chip():
|
||||
cpus = parse_cpus(instances("CIM_Processor.pull.xml"), instances("CIM_Chip.pull.xml"))
|
||||
assert cpus == [
|
||||
{
|
||||
"socket": "CPU 0",
|
||||
"model": "Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz",
|
||||
"cores": None,
|
||||
"threads": None,
|
||||
"status": "OK",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cpu_status", "status"),
|
||||
[("1", "OK"), ("4", "OK"), ("2", "Warning"), ("3", "Critical"), ("0", ""), ("", "")],
|
||||
)
|
||||
def test_cpu_status_follows_cpustatus(cpu_status, status):
|
||||
(cpu,) = parse_cpus([{"DeviceID": "CPU 0", "CPUStatus": cpu_status}], [])
|
||||
assert cpu["status"] == status
|
||||
assert cpu["model"] == ""
|
||||
|
||||
|
||||
def test_memory():
|
||||
dimms = parse_memory(instances("CIM_PhysicalMemory.pull.xml"))
|
||||
assert dimms[0] == {
|
||||
"slot": "BANK 0",
|
||||
"size_bytes": 17179869184,
|
||||
"type": "DDR4",
|
||||
"speed_mts": 2400,
|
||||
"part_number": "CT16G4SFD824A.M16FE",
|
||||
"serial_number": "E0E8D670",
|
||||
"status": "",
|
||||
}
|
||||
assert [d["slot"] for d in dimms] == ["BANK 0", "BANK 2"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "name"),
|
||||
[("24", "DDR3"), ("26", "DDR4"), ("29", "LPDDR3"), ("34", "DDR5"), ("2", ""), ("", "")],
|
||||
)
|
||||
def test_memory_type_follows_smbios(code, name):
|
||||
(dimm,) = parse_memory([{"BankLabel": "BANK 0", "MemoryType": code}])
|
||||
assert dimm["type"] == name
|
||||
|
||||
|
||||
def test_memory_without_a_configured_speed_falls_back_to_the_maximum():
|
||||
(dimm,) = parse_memory(
|
||||
[{"BankLabel": "A", "ConfiguredMemoryClockSpeed": "0", "MaxMemorySpeed": "2133"}]
|
||||
)
|
||||
assert dimm["speed_mts"] == 2133
|
||||
|
||||
|
||||
def test_memory_slot_falls_back_to_the_tag():
|
||||
(dimm,) = parse_memory([{"Tag": "ChannelA-DIMM0", "Capacity": "8589934592"}])
|
||||
assert dimm["slot"] == "ChannelA-DIMM0"
|
||||
assert dimm["size_bytes"] == 8589934592
|
||||
|
||||
|
||||
def test_drives_size_is_in_kilobytes_of_1000():
|
||||
drives = parse_drives(instances("CIM_MediaAccessDevice.pull.xml"))
|
||||
# 500107862 KB is the classic 500 GB disk (976773168 sectors of 512 bytes).
|
||||
assert drives == [
|
||||
{
|
||||
"slot": "MEDIA DEV 0",
|
||||
"model": "",
|
||||
"serial_number": "",
|
||||
"size_bytes": 960197124000,
|
||||
"media": "",
|
||||
"protocol": "",
|
||||
"status": "",
|
||||
},
|
||||
{
|
||||
"slot": "MEDIA DEV 1",
|
||||
"model": "",
|
||||
"serial_number": "",
|
||||
"size_bytes": 500107862000,
|
||||
"media": "",
|
||||
"protocol": "",
|
||||
"status": "",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("operational", "status"), [("2", "OK"), ("3", "Warning"), ("6", "Critical"), ("0", "")]
|
||||
)
|
||||
def test_drive_status_follows_operational_status(operational, status):
|
||||
(drive,) = parse_drives([{"DeviceID": "MEDIA DEV 0", "OperationalStatus": operational}])
|
||||
assert drive["status"] == status
|
||||
assert drive["size_bytes"] is None
|
||||
@@ -0,0 +1,75 @@
|
||||
"""AMT's hardware event log (AMT_MessageLog records), from the recorded NUC9."""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_amt.parsers import parse_event_record
|
||||
|
||||
from ..conftest import instances
|
||||
|
||||
|
||||
def _records():
|
||||
(out,) = instances("AMT_MessageLog.GetRecords.xml")
|
||||
return out["RecordArray"]
|
||||
|
||||
|
||||
def test_failed_logins_are_critical():
|
||||
entry = parse_event_record(_records()[0], "1")
|
||||
assert entry == {
|
||||
"id": "1",
|
||||
"timestamp": 1704511587.0,
|
||||
"severity": "Critical",
|
||||
"message": "Intel(r) ME: Authentication failed 10 times. The system may be under attack.",
|
||||
}
|
||||
|
||||
|
||||
def test_bios_progress_is_ok():
|
||||
entry = parse_event_record(_records()[1], "2")
|
||||
assert entry["severity"] == "OK"
|
||||
assert entry["message"] == "BIOS: PCI resource configuration"
|
||||
assert entry["timestamp"] == 1694565922.0
|
||||
|
||||
|
||||
def _record(sensor_type, offset, severity, entity, data=(0,) * 8, timestamp=1700000000):
|
||||
raw = (
|
||||
timestamp.to_bytes(4, "little")
|
||||
+ bytes([0xFF, sensor_type, 0x6F, offset, 0x68, severity, 0xFF, entity, 0])
|
||||
+ bytes(data)
|
||||
)
|
||||
return base64.b64encode(raw).decode()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("severity", "expected"),
|
||||
[
|
||||
(0, "OK"),
|
||||
(1, "OK"),
|
||||
(2, "OK"),
|
||||
(4, "OK"),
|
||||
(8, "Warning"),
|
||||
(16, "Critical"),
|
||||
(32, "Critical"),
|
||||
],
|
||||
)
|
||||
def test_severity(severity, expected):
|
||||
assert parse_event_record(_record(37, 0, severity, 34), "1")["severity"] == expected
|
||||
|
||||
|
||||
def test_firmware_error():
|
||||
entry = parse_event_record(_record(15, 0, 16, 34, (0, 1, 0, 0, 0, 0, 0, 0)), "1")
|
||||
assert entry["message"] == ("BIOS: No system memory is physically installed in the system.")
|
||||
|
||||
|
||||
def test_unknown_sensor_type_is_named():
|
||||
entry = parse_event_record(_record(200, 0, 2, 2), "1")
|
||||
assert entry["message"] == "Unknown: sensor type 200"
|
||||
|
||||
|
||||
def test_no_timestamp_is_none():
|
||||
assert parse_event_record(_record(30, 0, 16, 34, timestamp=0), "1")["timestamp"] is None
|
||||
|
||||
|
||||
def test_a_short_or_broken_record_is_skipped():
|
||||
assert parse_event_record("AAAA", "1") is None
|
||||
assert parse_event_record("not base64!", "1") is None
|
||||
@@ -0,0 +1,63 @@
|
||||
"""AMT's own network ports (AMT_EthernetPortSettings), from the recorded NUC9."""
|
||||
|
||||
from napalm_amt.parsers import parse_ethernet_ports
|
||||
|
||||
from ..conftest import instances
|
||||
|
||||
|
||||
def test_the_wired_port_is_kept_and_the_unused_wireless_one_dropped():
|
||||
# The NUC9 lists a wireless port without a MAC address: there is no WLAN card.
|
||||
ports = parse_ethernet_ports(instances("AMT_EthernetPortSettings.pull.xml"))
|
||||
assert ports == [
|
||||
{
|
||||
"name": "wired",
|
||||
"mac_address": "00:be:43:d8:22:a4",
|
||||
"link_up": True,
|
||||
"dhcp": True,
|
||||
"shared_ip": True,
|
||||
"ip": "",
|
||||
"prefix_length": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_a_static_address_with_its_mask():
|
||||
(port,) = parse_ethernet_ports(
|
||||
[
|
||||
{
|
||||
"InstanceID": "Intel(r) AMT Ethernet Port Settings 0",
|
||||
"MACAddress": "6C-4B-90-12-34-56",
|
||||
"LinkIsUp": "true",
|
||||
"DHCPEnabled": "false",
|
||||
"SharedStaticIp": "false",
|
||||
"IPAddress": "172.22.8.61",
|
||||
"SubnetMask": "255.255.255.0",
|
||||
"PhysicalConnectionType": "0",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert port["mac_address"] == "6c:4b:90:12:34:56"
|
||||
assert (port["ip"], port["prefix_length"]) == ("172.22.8.61", 24)
|
||||
assert port["dhcp"] is False
|
||||
assert port["shared_ip"] is False
|
||||
|
||||
|
||||
def test_a_wireless_port_with_a_card_is_named_wireless():
|
||||
ports = parse_ethernet_ports(
|
||||
[
|
||||
{
|
||||
"InstanceID": "Intel(r) AMT Ethernet Port Settings 1",
|
||||
"MACAddress": "a0-88-69-01-02-03",
|
||||
"PhysicalConnectionType": "3",
|
||||
"LinkIsUp": "false",
|
||||
},
|
||||
]
|
||||
)
|
||||
assert [(p["name"], p["link_up"]) for p in ports] == [("wireless", False)]
|
||||
|
||||
|
||||
def test_an_invalid_mask_gives_no_prefix():
|
||||
(port,) = parse_ethernet_ports(
|
||||
[{"MACAddress": "00-be-43-d8-22-a4", "IPAddress": "10.0.0.5", "SubnetMask": "255.0.255.0"}]
|
||||
)
|
||||
assert port["prefix_length"] is None
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The machine's power state and what AMT accepts, from the recorded NUC9."""
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_amt.parsers import POWER_REQUESTS, accepted_actions, parse_power_state
|
||||
|
||||
from ..conftest import instances
|
||||
|
||||
|
||||
def test_power_state_of_a_running_machine():
|
||||
(service,) = instances("CIM_AssociatedPowerManagementService.pull.xml")
|
||||
assert parse_power_state(service) == {
|
||||
"state": "On",
|
||||
"available": [10, 8, 5, 11, 4, 7, 14, 12],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "state"),
|
||||
[
|
||||
("2", "On"),
|
||||
("8", "Off"),
|
||||
("6", "Off"),
|
||||
("13", "Off"),
|
||||
# Asleep or hibernating: the OS is not running, On wakes it.
|
||||
("3", "Off"),
|
||||
("4", "Off"),
|
||||
("7", "Off"),
|
||||
("99", ""),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_power_state_codes(code, state):
|
||||
assert parse_power_state({"PowerState": code})["state"] == state
|
||||
|
||||
|
||||
def test_a_single_available_state_is_a_list():
|
||||
assert parse_power_state({"PowerState": "8", "AvailableRequestedPowerStates": "2"})[
|
||||
"available"
|
||||
] == [2]
|
||||
|
||||
|
||||
def test_running_machine_accepts_what_amt_offers_except_on():
|
||||
assert accepted_actions("On", [10, 8, 5, 11, 4, 7, 14, 12]) == [
|
||||
"GracefulShutdown",
|
||||
"ForceOff",
|
||||
"GracefulRestart",
|
||||
"ForceRestart",
|
||||
"PowerCycle",
|
||||
]
|
||||
|
||||
|
||||
def test_without_graceful_states_amt_offers_no_graceful_actions():
|
||||
# AMT lists the graceful states only where the host can honour them.
|
||||
assert accepted_actions("On", [10, 8, 5]) == ["ForceOff", "ForceRestart", "PowerCycle"]
|
||||
|
||||
|
||||
def test_machine_that_is_off_accepts_on_only():
|
||||
assert accepted_actions("Off", [2]) == ["On"]
|
||||
assert accepted_actions("Off", []) == ["On"]
|
||||
|
||||
|
||||
def test_unknown_state_accepts_nothing():
|
||||
assert accepted_actions("", [2, 8, 10]) == []
|
||||
|
||||
|
||||
def test_power_requests_cover_every_role_action():
|
||||
from napalm_device_types.models import POWER_ACTIONS
|
||||
|
||||
assert set(POWER_REQUESTS) == set(POWER_ACTIONS)
|
||||
assert POWER_REQUESTS["On"] == 2
|
||||
assert POWER_REQUESTS["ForceOff"] == 8
|
||||
assert POWER_REQUESTS["ForceRestart"] == 10
|
||||
@@ -0,0 +1,97 @@
|
||||
"""What AMT listens on: plain HTTP or TLS, the KVM console and IDE/USB redirection."""
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_amt.parsers import parse_management_protocols
|
||||
|
||||
from ..conftest import instances
|
||||
|
||||
|
||||
def _protocols(**overrides):
|
||||
args = {
|
||||
"tls_settings": instances("AMT_TLSSettingData.pull.xml"),
|
||||
"redirection": instances("AMT_RedirectionService.get.xml")[0],
|
||||
"kvm_settings": instances("IPS_KVMRedirectionSettingData.get.xml")[0],
|
||||
"kvm_saps": instances("CIM_KVMRedirectionSAP.pull.xml"),
|
||||
}
|
||||
args.update(overrides)
|
||||
return parse_management_protocols(**args)
|
||||
|
||||
|
||||
def test_amt_without_tls_listens_on_plain_http():
|
||||
assert _protocols() == {
|
||||
"ipmi": {"enabled": False, "port": 623},
|
||||
"ssh": {"enabled": False, "port": 22},
|
||||
"http": {"enabled": True, "port": 16992},
|
||||
"https": {"enabled": False, "port": 16993},
|
||||
"snmp": {"enabled": False, "port": 161},
|
||||
# KVM is enabled (MEBx), offline until someone connects; over the redirection
|
||||
# port, as the standard VNC port 5900 is off.
|
||||
"kvm": {"enabled": True, "port": 16994},
|
||||
# IDE-R/USB-R and SOL are both on (EnabledState 32771).
|
||||
"virtual_media": {"enabled": True, "port": 16994},
|
||||
}
|
||||
|
||||
|
||||
def test_amt_with_tls_listens_on_16993_and_redirects_over_tls():
|
||||
tls = [
|
||||
{
|
||||
"InstanceID": "Intel(r) AMT 802.3 TLS Settings",
|
||||
"Enabled": "true",
|
||||
"AcceptNonSecureConnections": "false",
|
||||
}
|
||||
]
|
||||
protocols = _protocols(tls_settings=tls)
|
||||
assert protocols["https"] == {"enabled": True, "port": 16993}
|
||||
assert protocols["http"] == {"enabled": False, "port": 16992}
|
||||
assert protocols["kvm"]["port"] == 16995
|
||||
assert protocols["virtual_media"]["port"] == 16995
|
||||
|
||||
|
||||
def test_tls_that_still_accepts_plain_connections_keeps_http_open():
|
||||
tls = [
|
||||
{
|
||||
"InstanceID": "Intel(r) AMT 802.3 TLS Settings",
|
||||
"Enabled": "true",
|
||||
"AcceptNonSecureConnections": "true",
|
||||
}
|
||||
]
|
||||
assert _protocols(tls_settings=tls)["http"]["enabled"] is True
|
||||
|
||||
|
||||
def test_the_lms_tls_setting_is_local_and_ignored():
|
||||
tls = [{"InstanceID": "Intel(r) AMT LMS TLS Settings", "Enabled": "true"}]
|
||||
assert _protocols(tls_settings=tls)["https"]["enabled"] is False
|
||||
|
||||
|
||||
def test_kvm_on_the_standard_vnc_port():
|
||||
kvm = {**instances("IPS_KVMRedirectionSettingData.get.xml")[0], "Is5900PortEnabled": "true"}
|
||||
assert _protocols(kvm_settings=kvm)["kvm"] == {"enabled": True, "port": 5900}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sap_state", ["3", "4"])
|
||||
def test_kvm_disabled(sap_state):
|
||||
saps = [{"EnabledState": sap_state}]
|
||||
assert _protocols(kvm_saps=saps)["kvm"]["enabled"] is False
|
||||
|
||||
|
||||
def test_kvm_disabled_in_mebx():
|
||||
kvm = {**instances("IPS_KVMRedirectionSettingData.get.xml")[0], "EnabledByMEBx": "false"}
|
||||
assert _protocols(kvm_settings=kvm)["kvm"]["enabled"] is False
|
||||
|
||||
|
||||
def test_kvm_unknown_without_any_data():
|
||||
assert _protocols(kvm_settings={}, kvm_saps=[])["kvm"]["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "enabled"),
|
||||
[("32768", False), ("32769", True), ("32770", False), ("32771", True), ("", False)],
|
||||
)
|
||||
def test_virtual_media_is_ide_redirection(state, enabled):
|
||||
assert _protocols(redirection={"EnabledState": state})["virtual_media"]["enabled"] is enabled
|
||||
|
||||
|
||||
def test_redirection_listener_off_closes_virtual_media():
|
||||
redirection = {"EnabledState": "32771", "ListenerEnabled": "false"}
|
||||
assert _protocols(redirection=redirection)["virtual_media"]["enabled"] is False
|
||||
@@ -0,0 +1,98 @@
|
||||
"""The machine's identity and AMT's firmware, from the recorded NUC9 (AMT 12.0.67)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_amt.parsers import (
|
||||
parse_firmware,
|
||||
parse_platform_guid,
|
||||
parse_software_identity,
|
||||
parse_system_identity,
|
||||
uuid_from_base64,
|
||||
)
|
||||
|
||||
from ..conftest import instances
|
||||
|
||||
|
||||
def test_software_identity_gives_the_amt_version_with_its_build():
|
||||
identity = parse_software_identity(instances("CIM_SoftwareIdentity.pull.xml"))
|
||||
assert identity["amt"] == "12.0.67.1579"
|
||||
assert identity["flash"] == "12.0.67"
|
||||
|
||||
|
||||
def test_software_identity_without_build_keeps_the_version():
|
||||
identity = parse_software_identity([{"InstanceID": "AMT", "VersionString": "11.8.50"}])
|
||||
assert identity["amt"] == "11.8.50"
|
||||
|
||||
|
||||
def test_software_identity_of_nothing_is_empty():
|
||||
assert parse_software_identity([]) == {"amt": "", "flash": ""}
|
||||
|
||||
|
||||
def test_platform_guid_is_the_smbios_uuid():
|
||||
# AMT sends the SMBIOS UUID's bytes as stored; the first three groups are
|
||||
# little-endian, as dmidecode and Intel's own console decode them.
|
||||
(package,) = instances("CIM_ComputerSystemPackage.pull.xml")
|
||||
assert parse_platform_guid(package["PlatformGUID"]) == "55e3ae13-bfd2-61bb-17a0-88aedd7037ea"
|
||||
|
||||
|
||||
def test_get_uuid_and_platform_guid_agree():
|
||||
(out,) = instances("AMT_SetupAndConfigurationService.GetUuid.xml")
|
||||
assert uuid_from_base64(out["UUID"]) == "55e3ae13-bfd2-61bb-17a0-88aedd7037ea"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("guid", ["", "zz", "13AEE355", "0" * 32, "F" * 32])
|
||||
def test_platform_guid_placeholders_and_garbage_are_empty(guid):
|
||||
assert parse_platform_guid(guid) == ""
|
||||
|
||||
|
||||
def test_system_identity_comes_from_the_chassis():
|
||||
identity = parse_system_identity(
|
||||
instances("CIM_Chassis.pull.xml"), instances("CIM_Card.pull.xml")
|
||||
)
|
||||
assert identity == {
|
||||
"manufacturer": "Intel(R) Client Systems",
|
||||
"model": "NUC9V7QNX",
|
||||
"serial_number": "JRQN0243007J",
|
||||
"board_model": "NUC9V7QNB",
|
||||
"board_serial": "KNQN0221020W",
|
||||
}
|
||||
|
||||
|
||||
def test_system_identity_falls_back_to_the_board_for_placeholders():
|
||||
chassis = [
|
||||
{
|
||||
"Manufacturer": "Default string",
|
||||
"Model": "To Be Filled By O.E.M.",
|
||||
"SerialNumber": "Default string",
|
||||
}
|
||||
]
|
||||
card = [{"Manufacturer": "LENOVO", "Model": "3102", "SerialNumber": "L1HF8AB01XY"}]
|
||||
identity = parse_system_identity(chassis, card)
|
||||
assert identity["manufacturer"] == "LENOVO"
|
||||
assert identity["model"] == "3102"
|
||||
assert identity["serial_number"] == "L1HF8AB01XY"
|
||||
|
||||
|
||||
def test_system_identity_of_nothing_is_empty():
|
||||
assert parse_system_identity([], []) == {
|
||||
"manufacturer": "",
|
||||
"model": "",
|
||||
"serial_number": "",
|
||||
"board_model": "",
|
||||
"board_serial": "",
|
||||
}
|
||||
|
||||
|
||||
def test_firmware_lists_amt_and_the_bios():
|
||||
firmware = parse_firmware(
|
||||
parse_software_identity(instances("CIM_SoftwareIdentity.pull.xml")),
|
||||
instances("CIM_BIOSElement.pull.xml"),
|
||||
)
|
||||
assert firmware == [
|
||||
{"name": "Intel AMT", "version": "12.0.67.1579", "updateable": None},
|
||||
{"name": "BIOS", "version": "QNCFLX70.0054.2020.0810.2227", "updateable": None},
|
||||
]
|
||||
|
||||
|
||||
def test_firmware_skips_what_is_unknown():
|
||||
assert parse_firmware({"amt": "", "flash": ""}, []) == []
|
||||
Reference in new issue
Block a user