- WS-Management answers are parsed with defusedxml (new dependency, defusedxml>=0.7) with DTDs forbidden. The check for "<!DOCTYPE" in the raw bytes it replaces missed a DTD in a UTF-16 answer, which expat reads too; on plain HTTP (16992) anyone in the path can send one. An answer with a DTD in any encoding is now refused as no WS-Management answer, as a UTF-8 one already was (B314, B405). - xml.sax.saxutils only escapes the request envelopes, nothing parses with SAX (nosec B406). - python -m napalm_amt.record fetches the landing page without certificate checks, as before: it sends no credentials and records what discovery sees, and AMT's certificate is self-signed unless one was provisioned (nosec B501). The driver itself still verifies by default (verify_ssl).
161 lines
6.3 KiB
Python
161 lines
6.3 KiB
Python
"""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:
|
|
# Recorded as discovery sees it: no credentials go out, and AMT's own certificate
|
|
# on 16993 is self-signed unless one was provisioned.
|
|
response = requests.get(url, timeout=10, verify=False, allow_redirects=True) # nosec B501
|
|
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())
|