feat: NAPALM driver for Windows over PowerShell Remoting

Talks PSRP over WinRM via pypsrp, one runspace pool per connection. Each
getter sends one PowerShell script that projects cmdlet results onto flat
fields and ends in ConvertTo-Json, so the Python side parses JSON, not text.

Covers facts, interfaces, IP addresses, ARP, routes and services, plus
service start/stop/restart/enable/disable. Service names are validated and
quoted as PowerShell verbatim strings, typographic quotes included.

Fixtures are synthetic: they pin down the JSON the scripts are designed to
emit. tools/harvest.py records the real output from a host.

Refs christianmanivong/netork#300
This commit is contained in:
Christian Manivong
2026-09-24 09:23:25 +02:00
commit 1ce42ef099
18 changed files with 1435 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Raw, unsanitised harvest output — never commit device data.
tools/harvest-out/
+10
View File
@@ -0,0 +1,10 @@
# Changelog
## 0.1.0 — unreleased
- PowerShell Remoting transport over WinRM (pypsrp), one runspace pool per connection.
- `get_facts`, `get_interfaces`, `get_interfaces_ip`, `get_arp_table`, `get_route_to`.
- `get_services` and `manage_service` (start, stop, restart, enable, disable).
- Discovery fingerprints: WinRM ports 5985/5986, `Microsoft-HTTPAPI` / `Microsoft-IIS`
server headers, Windows SNMP `sysDescr`, OpenSSH-for-Windows banner.
- `tools/harvest.py` to record real script output as test fixtures.
+75
View File
@@ -0,0 +1,75 @@
# napalm-windows
NAPALM driver for Windows Server 2016+ and Windows 10/11, part of the netOrk
driver family built on [`napalm-device-types`](https://git.netork.io/christianmanivong/napalm-device-types).
Driver name: `windows` · Role: `OSDriver` · Transport: PowerShell Remoting
(PSRP) over WinRM via [`pypsrp`](https://github.com/jborean93/pypsrp).
## How it works
Every getter sends one PowerShell script. The script projects the cmdlet
results onto flat, primitive fields and ends in `ConvertTo-Json`, so the
Python side parses JSON, never text. All device I/O goes through a single
seam (`PsrpTransport.run(script) -> str`), which is what the tests replace.
One runspace pool stays open per connection, so a poll pays the WinRM
handshake once.
## Supported
| Method | Source |
|---|---|
| `get_facts` | `Win32_ComputerSystem`, `Win32_OperatingSystem`, `Win32_BIOS`, registry (`DisplayVersion`, `UBR`) |
| `get_interfaces` | `Get-NetAdapter` |
| `get_interfaces_ip` | `Get-NetIPAddress` (loopback dropped, IPv6 zone index stripped) |
| `get_arp_table` | `Get-NetNeighbor -AddressFamily IPv4` (broadcast, multicast, unreachable dropped) |
| `get_route_to` | `Get-NetRoute` (multicast, broadcast, loopback dropped) |
| `get_services` / `manage_service` | `Win32_Service`, `Start-/Stop-/Restart-Service`, `Set-Service -StartupType` |
`running_kernel` carries the full build including the update revision
(`10.0.20348.2340`): Windows ships fixes as UBR bumps, which is what CVE
matching has to compare against.
Not yet: packages, Windows Update, scheduled tasks, health metrics, Hyper-V.
See netork#300 for the plan.
## Preparing a host
WinRM is on by default on Windows Server; on Windows 10/11 run
`Enable-PSRemoting` once. Then either
* **HTTPS (5986, default)** — needs a certificate-backed HTTPS listener, or
* **HTTP (5985)** — set the device port to 5985. Traffic is still encrypted at
message level (NTLM/Kerberos), credentials never travel in the clear.
A **local** administrator account additionally needs
`LocalAccountTokenFilterPolicy = 1` under
`HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System`; without it
remote UAC strips the admin token. Domain accounts are not affected.
## optional_args
| Key | Default | Meaning |
|---|---|---|
| `port` | `5986` | WinRM port |
| `winrm_ssl` | `port != 5985` | force HTTPS on or off |
| `winrm_auth` | `negotiate` | pypsrp auth protocol (`negotiate`, `ntlm`, `kerberos`, `credssp`, `basic`) |
| `ssl_verify` | `True` | validate the WinRM certificate |
## Tests and fixtures
```bash
pip install -e ".[dev]"
pytest
```
`tests/fixtures/synthetic/` holds the JSON the scripts are *designed* to emit.
To record the real thing from a host:
```bash
python tools/harvest.py <host> <user> <label> [--port 5985] [--insecure]
```
Output lands in `tools/harvest-out/` (gitignored); scrub it before copying
anything into `tests/`.
+5
View File
@@ -0,0 +1,5 @@
"""NAPALM driver for Windows hosts via PowerShell Remoting (WinRM)."""
from napalm_windows.windows import WindowsDriver
__all__ = ["WindowsDriver"]
+122
View File
@@ -0,0 +1,122 @@
"""PowerShell Remoting (PSRP) over WinRM — the driver's only path to the host.
One runspace pool stays open for the lifetime of the connection, so a poll
that sends a dozen scripts pays the WinRM handshake once. Everything the
driver knows about pypsrp lives in this module; the driver itself sees a
``run(script) -> str`` seam, which is what its tests replace and what an SSH
transport (Windows OpenSSH) would implement later.
"""
from __future__ import annotations
import logging
import requests
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
from pypsrp.exceptions import AuthenticationError, WinRMError, WinRMTransportError
from pypsrp.powershell import PowerShell, RunspacePool
from pypsrp.wsman import WSMan
logger = logging.getLogger(__name__)
class PowerShellError(Exception):
"""A script ran but wrote to PowerShell's error stream."""
class PsrpTransport:
def __init__(
self,
host: str,
username: str,
password: str,
*,
port: int,
ssl: bool,
cert_validation: bool,
auth: str,
timeout: int,
) -> None:
self.host = host
self.username = username
self._password = password
self.port = port
self.ssl = ssl
self.cert_validation = cert_validation
self.auth = auth
self.timeout = timeout
self._wsman: WSMan | None = None
self._pool: RunspacePool | None = None
def __repr__(self) -> str:
scheme = "https" if self.ssl else "http"
return f"<PsrpTransport {self.username}@{scheme}://{self.host}:{self.port}>"
@property
def is_open(self) -> bool:
return self._pool is not None
def open(self) -> None:
# encryption="auto" gives message-level encryption on plain HTTP when
# the auth protocol supports it (NTLM/Kerberos), so 5985 does not mean
# credentials or output travel in the clear.
self._wsman = WSMan(
self.host,
port=self.port,
username=self.username,
password=self._password,
ssl=self.ssl,
auth=self.auth,
cert_validation=self.cert_validation,
connection_timeout=self.timeout,
read_timeout=self.timeout,
operation_timeout=max(self.timeout - 10, 20),
encryption="auto",
)
pool = RunspacePool(self._wsman)
try:
pool.open()
except AuthenticationError as exc:
self._drop()
raise ConnectionException(f"Authentication failed for {self.username}: {exc}") from exc
except WinRMTransportError as exc:
self._drop()
if exc.code == 401:
raise ConnectionException(
f"Authentication failed for {self.username}: HTTP 401"
) from exc
raise ConnectionException(f"WinRM error from {self.host}:{self.port}: {exc}") from exc
except (requests.RequestException, WinRMError, OSError) as exc:
self._drop()
raise ConnectionException(
f"Cannot reach WinRM on {self.host}:{self.port}: {exc}"
) from exc
self._pool = pool
def run(self, script: str) -> str:
if self._pool is None:
raise ConnectionClosedException("WinRM connection is not open")
ps = PowerShell(self._pool)
ps.add_script(script)
output = ps.invoke()
if ps.had_errors:
message = "; ".join(str(e) for e in ps.streams.error).strip()
raise PowerShellError(message or "PowerShell reported an error")
return "\n".join(str(o) for o in output if o is not None)
def close(self) -> None:
if self._pool is not None:
try:
self._pool.close()
except Exception: # a dead connection is closed enough
logger.debug("Closing runspace pool on %s failed", self.host, exc_info=True)
self._drop()
def _drop(self) -> None:
if self._wsman is not None:
try:
self._wsman.close()
except Exception:
logger.debug("Closing WSMan session on %s failed", self.host, exc_info=True)
self._wsman = None
self._pool = None
+460
View File
@@ -0,0 +1,460 @@
# Licensed under the Apache License, Version 2.0
"""NAPALM driver for Windows hosts.
Talks PowerShell Remoting over WinRM (see :mod:`napalm_windows.transport`).
Each getter sends one script that projects its cmdlet results onto flat,
primitive fields and ends in ``ConvertTo-Json``; the Python side only
normalises formats. Two Windows PowerShell 5.1 quirks shape every script:
* ``ConvertTo-Json`` writes enums as integers and ``DateTime`` as
``"\\/Date(...)\\/"`` — so enums are cast with ``[string]`` and times are
converted to numbers before they leave the host.
* A one-element array arrives as a bare object — so list results go through
``-InputObject @(...)``, and :func:`_as_list` guards the rest.
"""
from __future__ import annotations
import ipaddress
import json
import logging
import re
from typing import Any
from napalm.base.exceptions import ConnectionClosedException
from napalm_device_types import FingerprintRule, OSDriver, PortSpec
from napalm_device_types.models import ServiceDict
from napalm_windows.transport import PowerShellError, PsrpTransport
logger = logging.getLogger(__name__)
WINRM_HTTP_PORT = 5985
WINRM_HTTPS_PORT = 5986
# ---------------------------------------------------------------------------
# PowerShell scripts — one per getter
# ---------------------------------------------------------------------------
_PS_FACTS = r"""
$ErrorActionPreference = 'Stop'
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$bios = Get-CimInstance -ClassName Win32_BIOS
$cv = Get-ItemProperty -ErrorAction SilentlyContinue `
-Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
[pscustomobject]@{
hostname = $env:COMPUTERNAME
dns_hostname = $cs.DNSHostName
domain = $cs.Domain
part_of_domain = [bool]$cs.PartOfDomain
manufacturer = $cs.Manufacturer
model = $cs.Model
serial = $bios.SerialNumber
caption = $os.Caption
version = $os.Version
display_version = $cv.DisplayVersion
ubr = $cv.UBR
uptime = [int64]((Get-Date) - $os.LastBootUpTime).TotalSeconds
interfaces = @(Get-NetAdapter | ForEach-Object { $_.Name })
} | ConvertTo-Json -Compress -Depth 3
"""
_PS_INTERFACES = r"""
$ErrorActionPreference = 'Stop'
ConvertTo-Json -Compress -Depth 3 -InputObject @(
Get-NetAdapter | ForEach-Object {
[pscustomobject]@{
name = $_.Name
description = $_.InterfaceDescription
status = [string]$_.Status
mtu = $_.MtuSize
speed = $_.Speed
mac = $_.MacAddress
}
}
)
"""
_PS_INTERFACES_IP = r"""
$ErrorActionPreference = 'Stop'
ConvertTo-Json -Compress -Depth 3 -InputObject @(
Get-NetIPAddress | ForEach-Object {
[pscustomobject]@{
interface = $_.InterfaceAlias
address = $_.IPAddress
prefix = [int]$_.PrefixLength
family = [string]$_.AddressFamily
}
}
)
"""
_PS_ARP = r"""
$ErrorActionPreference = 'Stop'
ConvertTo-Json -Compress -Depth 3 -InputObject @(
Get-NetNeighbor -AddressFamily IPv4 | ForEach-Object {
[pscustomobject]@{
interface = $_.InterfaceAlias
ip = $_.IPAddress
mac = $_.LinkLayerAddress
state = [string]$_.State
}
}
)
"""
_PS_ROUTES = r"""
$ErrorActionPreference = 'Stop'
ConvertTo-Json -Compress -Depth 3 -InputObject @(
Get-NetRoute | ForEach-Object {
[pscustomobject]@{
destination = $_.DestinationPrefix
next_hop = $_.NextHop
interface = $_.InterfaceAlias
metric = [int]$_.RouteMetric
protocol = [string]$_.Protocol
}
}
)
"""
_PS_SERVICES = r"""
$ErrorActionPreference = 'Stop'
ConvertTo-Json -Compress -Depth 3 -InputObject @(
Get-CimInstance -ClassName Win32_Service | ForEach-Object {
[pscustomobject]@{
name = $_.Name
state = [string]$_.State
start_mode = [string]$_.StartMode
pid = [int]$_.ProcessId
}
}
)
"""
_SERVICE_COMMANDS = {
"start": "Start-Service -Name {name}",
"stop": "Stop-Service -Name {name}",
"restart": "Restart-Service -Name {name}",
"enable": "Set-Service -Name {name} -StartupType Automatic",
"disable": "Set-Service -Name {name} -StartupType Disabled",
}
# Service key names, not display names: letters, digits and the few symbols
# real services use (MSSQL$SQLEXPRESS, CDPUserSvc_4a5b6, {GUID}-style names).
_SERVICE_NAME_RE = re.compile(r"^[\w.$@{}\-]+$", re.ASCII)
# PowerShell accepts these as single-quote delimiters alongside the ASCII one.
_PS_SINGLE_QUOTES = ("'", "‘", "’", "‚", "‛")
_ROUTE_PROTOCOLS = {
"local": "connected",
"routeradvertisement": "connected",
"netmgmt": "static",
"dhcp": "dhcp",
"ospf": "ospf",
"bgp": "bgp",
"rip": "rip",
}
_ENABLED_START_MODES = {"auto", "boot", "system"}
_DEAD_NEIGHBOUR_STATES = {"unreachable", "incomplete"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _as_list(value: Any) -> list[Any]:
"""Undo ConvertTo-Json unwrapping a one-element array into a bare value."""
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def _ps_quote(value: str) -> str:
"""Quote ``value`` as a PowerShell single-quoted (verbatim) string."""
for q in _PS_SINGLE_QUOTES:
value = value.replace(q, q + q)
return f"'{value}'"
def _mac(value: str | None) -> str:
"""``00-15-5d-01-02-03`` → ``00:15:5D:01:02:03``."""
if not value:
return ""
return value.replace("-", ":").upper()
def _is_real_neighbour_mac(mac: str) -> bool:
if not mac or mac == "00:00:00:00:00:00":
return False
# Broadcast and multicast share the group bit in the first octet.
return not int(mac.split(":", 1)[0], 16) & 1
def _is_noise_route(destination: str) -> bool:
"""Multicast, limited broadcast and loopback — present on every host."""
try:
net = ipaddress.ip_network(destination, strict=False)
except ValueError:
return False
if net.is_multicast or net.is_loopback:
return True
return str(net) == "255.255.255.255/32"
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
class WindowsDriver(OSDriver):
"""NAPALM driver for Windows Server 2016+ and Windows 10/11.
``optional_args``:
* ``port`` — WinRM port, default 5986 (HTTPS).
* ``winrm_ssl`` — force HTTPS on/off; default is HTTPS unless port is 5985.
* ``winrm_auth`` — pypsrp auth protocol, default ``negotiate``.
* ``ssl_verify`` — validate the WinRM certificate, default ``True``.
"""
TYPE_LABEL = "Windows"
VENDOR = "Microsoft"
DRIVER_NAME = "windows"
USES_SSH = False
# Updates install during shutdown and boot; a Windows reboot routinely
# takes several minutes rather than a Linux host's ninety seconds.
REBOOT_SETTLE_SECONDS = 180
#: Read by netOrk when the device has no port of its own.
default_port = WINRM_HTTPS_PORT
PORT_SPECS = [
PortSpec("http", WINRM_HTTP_PORT, ("/wsman",), weight=6.0),
PortSpec("https", WINRM_HTTPS_PORT, ("/wsman",), weight=6.0),
]
# WinRM answers through http.sys: "Server: Microsoft-HTTPAPI/2.0". Discovery
# only reaches 5985 when port 80 is silent, so an IIS on 80 is the header
# it sees instead.
HTTP_FINGERPRINT = [
FingerprintRule("microsoft-httpapi", weight=5.0),
FingerprintRule("microsoft-iis", weight=3.0),
]
# sysDescr: "Hardware: ... Software: Windows Version 6.3 (Build 17763 ...)"
SNMP_FINGERPRINT = [
FingerprintRule("software: windows", weight=8.0),
]
# Banner: "SSH-2.0-OpenSSH_for_Windows_8.1"
SSH_FINGERPRINT = [
FingerprintRule("openssh_for_windows", weight=8.0),
]
def __init__(
self,
hostname: str,
username: str,
password: str,
timeout: int = 60,
optional_args: dict | None = None,
) -> None:
self.hostname = hostname
self.username = username
self.password = password
self.timeout = timeout
optional_args = optional_args or {}
self.port: int = int(optional_args.get("port") or WINRM_HTTPS_PORT)
self.ssl: bool = bool(optional_args.get("winrm_ssl", self.port != WINRM_HTTP_PORT))
self.cert_validation: bool = bool(optional_args.get("ssl_verify", True))
self.auth: str = optional_args.get("winrm_auth", "negotiate")
self._transport: PsrpTransport | None = None
# ------------------------------------------------------------------
# Connection management
# ------------------------------------------------------------------
def open(self) -> None:
transport = PsrpTransport(
self.hostname,
self.username,
self.password,
port=self.port,
ssl=self.ssl,
cert_validation=self.cert_validation,
auth=self.auth,
timeout=self.timeout,
)
transport.open()
self._transport = transport
def close(self) -> None:
if self._transport is not None:
self._transport.close()
self._transport = None
def is_alive(self) -> dict[str, bool]:
return {"is_alive": bool(self._transport and self._transport.is_open)}
def _run_ps(self, script: str) -> Any:
"""Run ``script`` and return its JSON output parsed, or ``None`` if empty."""
out = self._run_raw(script).strip()
return json.loads(out) if out else None
def _run_raw(self, script: str) -> str:
if self._transport is None:
raise ConnectionClosedException("WinRM connection is not open")
return self._transport.run(script)
# ------------------------------------------------------------------
# NAPALM getters
# ------------------------------------------------------------------
def get_facts(self) -> dict[str, Any]:
raw = self._run_ps(_PS_FACTS) or {}
hostname = raw.get("dns_hostname") or raw.get("hostname") or ""
domain = raw.get("domain") or ""
fqdn = f"{hostname}.{domain}" if raw.get("part_of_domain") and domain else hostname
version = raw.get("version") or ""
ubr = raw.get("ubr")
build = version.rsplit(".", 1)[-1] if version else ""
if ubr is not None:
build = f"{build}.{ubr}"
os_version = " ".join(
p for p in ((raw.get("caption") or "").strip(), raw.get("display_version")) if p
)
if build:
os_version = f"{os_version} (build {build})"
return {
"hostname": hostname,
"fqdn": fqdn,
"vendor": raw.get("manufacturer") or self.VENDOR,
"model": raw.get("model") or "",
"serial_number": raw.get("serial") or "",
"os_version": os_version,
"uptime": int(raw.get("uptime") or 0),
"interface_list": [str(i) for i in _as_list(raw.get("interfaces"))],
# Full build including the update revision — what CVE matching
# needs, since Windows fixes ship as UBR bumps, not version bumps.
"running_kernel": f"{version}.{ubr}" if ubr is not None else version,
}
def get_interfaces(self) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for a in _as_list(self._run_ps(_PS_INTERFACES)):
status = (a.get("status") or "").lower()
result[a["name"]] = {
"is_up": status == "up",
"is_enabled": status != "disabled",
"description": a.get("description") or "",
"last_flapped": -1.0,
"speed": float(a.get("speed") or 0) / 1_000_000,
"mtu": int(a.get("mtu") or 0),
"mac_address": _mac(a.get("mac")),
}
return result
def get_interfaces_ip(self) -> dict[str, dict[str, dict[str, dict[str, int]]]]:
result: dict[str, dict[str, dict[str, dict[str, int]]]] = {}
for entry in _as_list(self._run_ps(_PS_INTERFACES_IP)):
# Link-local IPv6 carries its zone index ("fe80::1%6").
address = (entry.get("address") or "").split("%", 1)[0]
try:
if ipaddress.ip_address(address).is_loopback:
continue
except ValueError:
continue
family = "ipv6" if (entry.get("family") or "").lower() == "ipv6" else "ipv4"
iface = result.setdefault(entry["interface"], {})
iface.setdefault(family, {})[address] = {"prefix_length": int(entry["prefix"])}
return result
def get_arp_table(self, vrf: str = "") -> list[dict[str, Any]]:
entries = []
for n in _as_list(self._run_ps(_PS_ARP)):
if (n.get("state") or "").lower() in _DEAD_NEIGHBOUR_STATES:
continue
mac = _mac(n.get("mac"))
if not _is_real_neighbour_mac(mac):
continue
entries.append({"interface": n["interface"], "mac": mac, "ip": n["ip"], "age": 0.0})
return entries
def get_route_to(
self,
destination: str = "",
protocol: str = "",
longer: bool = False,
) -> dict[str, list[dict[str, Any]]]:
routes: dict[str, list[dict[str, Any]]] = {}
for r in _as_list(self._run_ps(_PS_ROUTES)):
network = r["destination"]
if _is_noise_route(network):
continue
if destination and network != destination:
continue
raw_proto = (r.get("protocol") or "").lower()
proto = _ROUTE_PROTOCOLS.get(raw_proto, raw_proto)
if protocol and proto != protocol.lower():
continue
next_hop = r.get("next_hop") or ""
if next_hop in ("0.0.0.0", "::"):
next_hop = ""
routes.setdefault(network, []).append(
{
"protocol": proto,
"family": "ipv6" if ":" in network else "ipv4",
"current_active": True,
"last_active": False,
"age": -1,
"next_hop": next_hop,
"outgoing_interface": r.get("interface") or "",
"selected_next_hop": True,
"preference": int(r.get("metric") or 0),
"routing_table": "global",
"protocol_attributes": {},
}
)
return routes
# ------------------------------------------------------------------
# Services
# ------------------------------------------------------------------
def get_services(self) -> list[ServiceDict]:
services: list[ServiceDict] = []
for s in _as_list(self._run_ps(_PS_SERVICES)):
running = (s.get("state") or "").lower() == "running"
services.append(
{
"name": s["name"],
"running": running,
"enabled": (s.get("start_mode") or "").lower() in _ENABLED_START_MODES,
"pid": int(s.get("pid") or 0) if running else 0,
}
)
return services
def manage_service(self, name: str, action: str) -> dict[str, Any]:
template = _SERVICE_COMMANDS.get(action)
if template is None:
raise ValueError(
f"Unsupported action {action!r}; use one of {sorted(_SERVICE_COMMANDS)}"
)
if not _SERVICE_NAME_RE.match(name or ""):
raise ValueError(f"Invalid service name {name!r}")
command = template.format(name=_ps_quote(name))
try:
output = self._run_raw(f"{command} -ErrorAction Stop | Out-Null")
except PowerShellError as exc:
return {"success": False, "output": str(exc)}
return {"success": True, "output": output}
+74
View File
@@ -0,0 +1,74 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-windows"
version = "0.1.0"
description = "NAPALM driver for Windows hosts via PowerShell Remoting (WinRM)"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "Apache-2.0" }
authors = [
{ name = "Christian Manivong", email = "christian@manivong.de" },
]
keywords = [
"napalm",
"network",
"automation",
"windows",
"winrm",
"powershell",
"psrp",
"driver",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Networking",
"Topic :: System :: Systems Administration",
"Typing :: Typed",
]
dependencies = [
"napalm>=4.0",
"napalm-device-types>=0.5.0",
"pypsrp>=0.8",
]
[project.optional-dependencies]
dev = [
"pytest",
"pytest-cov",
"ruff",
"mypy",
]
[project.entry-points."napalm.drivers"]
windows = "napalm_windows:WindowsDriver"
[project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-windows"
[tool.setuptools.packages.find]
where = ["."]
include = ["napalm_windows*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py39"
# Same rule set the netOrk repo gates on, so this driver is held to the
# standard of the project that consumes it rather than to ruff's defaults.
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
+10
View File
@@ -0,0 +1,10 @@
# Synthetic fixtures
These files are what the driver's PowerShell scripts *emit*, not raw device
output: each script projects its cmdlet results onto flat, primitive fields and
ends in `ConvertTo-Json`. The shape is therefore this driver's own contract,
and the Python side is tested against it here.
What they cannot prove is that the scripts produce this shape on a real host
(property names, Windows PowerShell 5.1 serialisation quirks). They are to be
replaced by output recorded from a Windows Server and a Windows 11 client.
+8
View File
@@ -0,0 +1,8 @@
[
{"interface": "Ethernet", "ip": "10.0.0.1", "mac": "00-0D-B9-11-22-33", "state": "Reachable"},
{"interface": "Ethernet", "ip": "10.0.0.20", "mac": "00-15-5D-AA-BB-CC", "state": "Stale"},
{"interface": "Ethernet", "ip": "10.0.0.255", "mac": "FF-FF-FF-FF-FF-FF", "state": "Permanent"},
{"interface": "Ethernet", "ip": "224.0.0.22", "mac": "01-00-5E-00-00-16", "state": "Permanent"},
{"interface": "Ethernet", "ip": "10.0.0.99", "mac": "00-00-00-00-00-00", "state": "Unreachable"},
{"interface": "Ethernet", "ip": "10.0.0.98", "mac": "", "state": "Incomplete"}
]
+1
View File
@@ -0,0 +1 @@
{"hostname": "DESKTOP-4F2K9", "dns_hostname": "DESKTOP-4F2K9", "domain": "WORKGROUP", "part_of_domain": false, "manufacturer": "LENOVO", "model": "21HDCTO1WW", "serial": "PF4ABCDE", "caption": "Microsoft Windows 11 Pro", "version": "10.0.26100", "display_version": "24H2", "ubr": 1742, "uptime": 3600, "interfaces": "Wi-Fi"}
+1
View File
@@ -0,0 +1 @@
{"hostname": "SRV-APP01", "dns_hostname": "srv-app01", "domain": "corp.example", "part_of_domain": true, "manufacturer": "Microsoft Corporation", "model": "Virtual Machine", "serial": "0000-0001-2345-6789-0123-4567-89", "caption": "Microsoft Windows Server 2022 Standard", "version": "10.0.20348", "display_version": "21H2", "ubr": 2340, "uptime": 86400, "interfaces": ["Ethernet", "Ethernet 2"]}
+5
View File
@@ -0,0 +1,5 @@
[
{"name": "Ethernet", "description": "Microsoft Hyper-V Network Adapter", "status": "Up", "mtu": 1500, "speed": 10000000000, "mac": "00-15-5D-01-02-03"},
{"name": "Ethernet 2", "description": "Intel(R) Ethernet Connection I219-LM", "status": "Disconnected", "mtu": 1500, "speed": 0, "mac": "8C-16-45-AA-BB-CC"},
{"name": "Wi-Fi", "description": "Intel(R) Wi-Fi 6E AX211 160MHz", "status": "Disabled", "mtu": null, "speed": null, "mac": ""}
]
+8
View File
@@ -0,0 +1,8 @@
[
{"interface": "Ethernet", "address": "10.0.0.5", "prefix": 24, "family": "IPv4"},
{"interface": "Ethernet", "address": "10.0.0.6", "prefix": 24, "family": "IPv4"},
{"interface": "Ethernet", "address": "fe80::1c2d:3e4f:5a6b:7c8d%6", "prefix": 64, "family": "IPv6"},
{"interface": "Ethernet", "address": "2001:db8::5", "prefix": 64, "family": "IPv6"},
{"interface": "Loopback Pseudo-Interface 1", "address": "127.0.0.1", "prefix": 8, "family": "IPv4"},
{"interface": "Loopback Pseudo-Interface 1", "address": "::1", "prefix": 128, "family": "IPv6"}
]
+10
View File
@@ -0,0 +1,10 @@
[
{"destination": "0.0.0.0/0", "next_hop": "10.0.0.1", "interface": "Ethernet", "metric": 0, "protocol": "NetMgmt"},
{"destination": "10.0.0.0/24", "next_hop": "0.0.0.0", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
{"destination": "192.168.50.0/24", "next_hop": "10.0.0.254", "interface": "Ethernet", "metric": 10, "protocol": "Dhcp"},
{"destination": "2001:db8::/64", "next_hop": "::", "interface": "Ethernet", "metric": 256, "protocol": "RouterAdvertisement"},
{"destination": "224.0.0.0/4", "next_hop": "0.0.0.0", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
{"destination": "255.255.255.255/32", "next_hop": "0.0.0.0", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
{"destination": "ff00::/8", "next_hop": "::", "interface": "Ethernet", "metric": 256, "protocol": "Local"},
{"destination": "127.0.0.0/8", "next_hop": "0.0.0.0", "interface": "Loopback Pseudo-Interface 1", "metric": 256, "protocol": "Local"}
]
+7
View File
@@ -0,0 +1,7 @@
[
{"name": "WinRM", "state": "Running", "start_mode": "Auto", "pid": 1234},
{"name": "Spooler", "state": "Stopped", "start_mode": "Manual", "pid": 0},
{"name": "MSSQL$SQLEXPRESS", "state": "Running", "start_mode": "Auto", "pid": 4321},
{"name": "RemoteRegistry", "state": "Stopped", "start_mode": "Disabled", "pid": 0},
{"name": "wuauserv", "state": "Stopped", "start_mode": "Manual", "pid": null}
]
+147
View File
@@ -0,0 +1,147 @@
"""Unit tests for the PSRP transport.
pypsrp is patched out at the module boundary: these tests pin down how the
transport configures it and how its failures reach the driver, not pypsrp itself.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
import requests
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
from pypsrp.exceptions import AuthenticationError, WinRMTransportError
from napalm_windows.transport import PowerShellError, PsrpTransport
def _transport(**overrides):
kwargs = {
"port": 5986,
"ssl": True,
"cert_validation": True,
"auth": "negotiate",
"timeout": 60,
}
kwargs.update(overrides)
return PsrpTransport("win01", "admin", "secret", **kwargs)
@pytest.fixture()
def psrp():
"""Patch WSMan, RunspacePool and PowerShell as the transport sees them."""
with (
patch("napalm_windows.transport.WSMan") as wsman,
patch("napalm_windows.transport.RunspacePool") as pool,
patch("napalm_windows.transport.PowerShell") as ps,
):
yield {"wsman": wsman, "pool": pool, "ps": ps}
class TestOpen:
def test_passes_connection_settings_to_wsman(self, psrp):
_transport(port=5985, ssl=False, cert_validation=False, auth="ntlm").open()
kwargs = psrp["wsman"].call_args.kwargs
assert psrp["wsman"].call_args.args == ("win01",)
assert kwargs["port"] == 5985
assert kwargs["ssl"] is False
assert kwargs["cert_validation"] is False
assert kwargs["auth"] == "ntlm"
assert kwargs["username"] == "admin"
assert kwargs["password"] == "secret" # noqa: S105
def test_opens_one_runspace_pool_on_the_connection(self, psrp):
_transport().open()
psrp["pool"].assert_called_once_with(psrp["wsman"].return_value)
psrp["pool"].return_value.open.assert_called_once_with()
def test_authentication_failure_is_a_connection_exception(self, psrp):
psrp["pool"].return_value.open.side_effect = AuthenticationError("bad creds")
with pytest.raises(ConnectionException, match="Authentication failed"):
_transport().open()
def test_http_401_is_an_authentication_failure(self, psrp):
psrp["pool"].return_value.open.side_effect = WinRMTransportError(
"http", 401, "Unauthorized"
)
with pytest.raises(ConnectionException, match="Authentication failed"):
_transport().open()
def test_unreachable_host_is_a_connection_exception(self, psrp):
psrp["pool"].return_value.open.side_effect = requests.ConnectionError("refused")
with pytest.raises(ConnectionException, match="win01:5986"):
_transport().open()
def test_is_open_only_after_open(self, psrp):
t = _transport()
assert t.is_open is False
t.open()
assert t.is_open is True
class TestRun:
def test_returns_output_objects_joined_by_newline(self, psrp):
psrp["ps"].return_value.invoke.return_value = ['{"a":', "1}"]
psrp["ps"].return_value.had_errors = False
t = _transport()
t.open()
assert t.run("Get-Thing") == '{"a":\n1}'
psrp["ps"].return_value.add_script.assert_called_once_with("Get-Thing")
def test_none_objects_are_skipped(self, psrp):
psrp["ps"].return_value.invoke.return_value = [None, "x", None]
psrp["ps"].return_value.had_errors = False
t = _transport()
t.open()
assert t.run("Get-Thing") == "x"
def test_error_stream_raises_powershell_error_with_its_text(self, psrp):
psrp["ps"].return_value.invoke.return_value = []
psrp["ps"].return_value.had_errors = True
psrp["ps"].return_value.streams.error = [
"Cannot find any service with service name 'nope'."
]
t = _transport()
t.open()
with pytest.raises(PowerShellError, match="service name 'nope'"):
t.run("Start-Service nope")
def test_run_before_open_raises_connection_closed(self):
with pytest.raises(ConnectionClosedException):
_transport().run("Get-Thing")
class TestClose:
def test_close_closes_pool_and_connection(self, psrp):
t = _transport()
t.open()
t.close()
psrp["pool"].return_value.close.assert_called_once_with()
psrp["wsman"].return_value.close.assert_called_once_with()
assert t.is_open is False
def test_close_swallows_errors_from_a_dead_connection(self, psrp):
psrp["pool"].return_value.close.side_effect = requests.ConnectionError("gone")
t = _transport()
t.open()
t.close()
assert t.is_open is False
def test_close_without_open_is_a_no_op(self):
_transport().close()
def test_transport_does_not_print_password_in_repr():
assert "secret" not in repr(_transport())
+412
View File
@@ -0,0 +1,412 @@
"""Unit tests for the Windows driver.
Every getter sends one PowerShell script and parses the JSON it emits. A fake
transport answers each script with a fixture from tests/fixtures/synthetic/ —
see the README there for what those fixtures do and do not prove.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from napalm.base.exceptions import ConnectionClosedException
from napalm_device_types import role_keys_of
from napalm_windows import WindowsDriver
from napalm_windows import windows as mod
from napalm_windows.transport import PowerShellError
FIXTURES = Path(__file__).parent / "fixtures" / "synthetic"
def _fixture(name: str) -> str:
return (FIXTURES / name).read_text()
class FakeTransport:
"""Answers the driver's scripts from fixtures and records what it was sent."""
def __init__(self, answers: dict[str, str] | None = None, error: str | None = None):
self.answers = answers or {}
self.error = error
self.sent: list[str] = []
self.is_open = True
def run(self, script: str) -> str:
self.sent.append(script)
if self.error:
raise PowerShellError(self.error)
return self.answers.get(script, "")
def close(self) -> None:
self.is_open = False
def _driver(**answers: str) -> WindowsDriver:
d = WindowsDriver("win01", "admin", "secret")
d._transport = FakeTransport({getattr(mod, k): v for k, v in answers.items()})
return d
# ---------------------------------------------------------------------------
# Construction and class attributes
# ---------------------------------------------------------------------------
class TestInit:
def test_defaults_to_winrm_over_https(self):
d = WindowsDriver("win01", "admin", "secret")
assert d.port == 5986
assert d.ssl is True
assert d.cert_validation is True
assert d.auth == "negotiate"
def test_port_5985_means_plain_http(self):
d = WindowsDriver("win01", "admin", "secret", optional_args={"port": 5985})
assert d.ssl is False
def test_winrm_ssl_overrides_the_port_guess(self):
d = WindowsDriver(
"win01", "admin", "secret", optional_args={"port": 8443, "winrm_ssl": False}
)
assert d.ssl is False
def test_ssl_verify_false_disables_cert_validation(self):
d = WindowsDriver("win01", "admin", "secret", optional_args={"ssl_verify": False})
assert d.cert_validation is False
def test_winrm_auth_is_passed_through(self):
d = WindowsDriver("win01", "admin", "secret", optional_args={"winrm_auth": "ntlm"})
assert d.auth == "ntlm"
def test_construction_does_no_io(self):
assert WindowsDriver("win01", "admin", "secret")._transport is None
class TestClassAttributes:
def test_driver_name_matches_the_entry_point(self):
# netork/core/nvd/platform.py already keys on the driver name "windows".
assert WindowsDriver.DRIVER_NAME == "windows"
def test_does_not_ask_for_ssh_credentials(self):
assert WindowsDriver.USES_SSH is False
def test_default_port_is_winrm_https(self):
assert WindowsDriver.default_port == 5986
def test_fills_the_general_purpose_os_role(self):
# OSDriver's role key is "linux" but means "general-purpose OS host"
# (poll timeout, OS tabs). Renaming it is tracked in netork#300.
assert role_keys_of(WindowsDriver) == ["linux"]
def test_wsman_ports_are_probed_during_discovery(self):
ports = {(p.scheme, p.port) for p in WindowsDriver.PORT_SPECS or []}
assert ("http", 5985) in ports
assert ("https", 5986) in ports
def test_http_sys_and_iis_server_headers_are_fingerprints(self):
patterns = {r.pattern for r in WindowsDriver.HTTP_FINGERPRINT}
assert {"microsoft-httpapi", "microsoft-iis"} <= patterns
def test_openssh_for_windows_banner_is_a_fingerprint(self):
patterns = {r.pattern for r in WindowsDriver.SSH_FINGERPRINT}
assert "openssh_for_windows" in patterns
# ---------------------------------------------------------------------------
# Connection lifecycle
# ---------------------------------------------------------------------------
class TestLifecycle:
def test_is_alive_false_before_open(self):
assert WindowsDriver("win01", "admin", "secret").is_alive() == {"is_alive": False}
def test_is_alive_follows_the_transport(self):
d = _driver()
assert d.is_alive() == {"is_alive": True}
def test_close_drops_the_transport(self):
d = _driver()
fake = d._transport
d.close()
assert fake.is_open is False
assert d._transport is None
def test_getter_before_open_raises_connection_closed(self):
with pytest.raises(ConnectionClosedException):
WindowsDriver("win01", "admin", "secret").get_facts()
# ---------------------------------------------------------------------------
# JSON handling
# ---------------------------------------------------------------------------
class TestRunPs:
def test_empty_output_is_none(self):
d = _driver()
assert d._run_ps("Get-Nothing") is None
def test_output_is_parsed_as_json(self):
d = WindowsDriver("win01", "admin", "secret")
d._transport = FakeTransport({"Get-X": '{"a": 1}'})
assert d._run_ps("Get-X") == {"a": 1}
class TestAsList:
"""ConvertTo-Json unwraps a one-element array into a bare object."""
def test_none_is_empty(self):
assert mod._as_list(None) == []
def test_single_object_is_wrapped(self):
assert mod._as_list({"a": 1}) == [{"a": 1}]
def test_list_is_unchanged(self):
assert mod._as_list([1, 2]) == [1, 2]
class TestPsQuote:
def test_wraps_in_single_quotes(self):
assert mod._ps_quote("Spooler") == "'Spooler'"
def test_doubles_ascii_single_quote(self):
assert mod._ps_quote("a'b") == "'a''b'"
@pytest.mark.parametrize("quote", ["\u2018", "\u2019", "\u201a", "\u201b"])
def test_doubles_typographic_quotes_powershell_also_accepts(self, quote):
# PowerShell treats these as single-quote delimiters too; leaving one
# undoubled would end the string early.
assert mod._ps_quote(f"a{quote}b") == f"'a{quote}{quote}b'"
class TestMac:
def test_windows_dashes_become_colons(self):
assert mod._mac("00-15-5d-01-02-03") == "00:15:5D:01:02:03"
def test_empty_stays_empty(self):
assert mod._mac("") == ""
assert mod._mac(None) == ""
# ---------------------------------------------------------------------------
# Getters
# ---------------------------------------------------------------------------
class TestGetFacts:
def test_domain_member_server(self):
facts = _driver(_PS_FACTS=_fixture("facts_server.json")).get_facts()
assert facts == {
"hostname": "srv-app01",
"fqdn": "srv-app01.corp.example",
"vendor": "Microsoft Corporation",
"model": "Virtual Machine",
"serial_number": "0000-0001-2345-6789-0123-4567-89",
"os_version": "Microsoft Windows Server 2022 Standard 21H2 (build 20348.2340)",
"uptime": 86400,
"interface_list": ["Ethernet", "Ethernet 2"],
"running_kernel": "10.0.20348.2340",
}
def test_workgroup_client_has_no_domain_suffix(self):
facts = _driver(_PS_FACTS=_fixture("facts_client.json")).get_facts()
assert facts["hostname"] == "DESKTOP-4F2K9"
assert facts["fqdn"] == "DESKTOP-4F2K9"
def test_single_interface_arrives_as_a_bare_string(self):
facts = _driver(_PS_FACTS=_fixture("facts_client.json")).get_facts()
assert facts["interface_list"] == ["Wi-Fi"]
def test_missing_manufacturer_falls_back_to_microsoft(self):
data = json.loads(_fixture("facts_client.json"))
data["manufacturer"] = None
facts = _driver(_PS_FACTS=json.dumps(data)).get_facts()
assert facts["vendor"] == "Microsoft"
def test_os_version_without_display_version(self):
# Server 2016 has no DisplayVersion registry value.
data = json.loads(_fixture("facts_server.json"))
data["caption"] = "Microsoft Windows Server 2016 Standard"
data["display_version"] = None
data["version"] = "10.0.14393"
data["ubr"] = 7428
facts = _driver(_PS_FACTS=json.dumps(data)).get_facts()
assert facts["os_version"] == "Microsoft Windows Server 2016 Standard (build 14393.7428)"
assert facts["running_kernel"] == "10.0.14393.7428"
class TestGetInterfaces:
def test_maps_adapters(self):
ifaces = _driver(_PS_INTERFACES=_fixture("interfaces.json")).get_interfaces()
assert ifaces["Ethernet"] == {
"is_up": True,
"is_enabled": True,
"description": "Microsoft Hyper-V Network Adapter",
"last_flapped": -1.0,
"speed": 10000.0,
"mtu": 1500,
"mac_address": "00:15:5D:01:02:03",
}
def test_disconnected_is_enabled_but_down(self):
ifaces = _driver(_PS_INTERFACES=_fixture("interfaces.json")).get_interfaces()
assert ifaces["Ethernet 2"]["is_up"] is False
assert ifaces["Ethernet 2"]["is_enabled"] is True
assert ifaces["Ethernet 2"]["speed"] == 0.0
def test_disabled_adapter_with_null_fields(self):
ifaces = _driver(_PS_INTERFACES=_fixture("interfaces.json")).get_interfaces()
assert ifaces["Wi-Fi"]["is_enabled"] is False
assert ifaces["Wi-Fi"]["mtu"] == 0
assert ifaces["Wi-Fi"]["speed"] == 0.0
assert ifaces["Wi-Fi"]["mac_address"] == ""
class TestGetInterfacesIp:
def test_groups_addresses_by_interface_and_family(self):
ips = _driver(_PS_INTERFACES_IP=_fixture("interfaces_ip.json")).get_interfaces_ip()
assert ips == {
"Ethernet": {
"ipv4": {
"10.0.0.5": {"prefix_length": 24},
"10.0.0.6": {"prefix_length": 24},
},
"ipv6": {
"fe80::1c2d:3e4f:5a6b:7c8d": {"prefix_length": 64},
"2001:db8::5": {"prefix_length": 64},
},
}
}
class TestGetArpTable:
def test_keeps_only_real_neighbours(self):
arp = _driver(_PS_ARP=_fixture("arp.json")).get_arp_table()
assert arp == [
{"interface": "Ethernet", "mac": "00:0D:B9:11:22:33", "ip": "10.0.0.1", "age": 0.0},
{"interface": "Ethernet", "mac": "00:15:5D:AA:BB:CC", "ip": "10.0.0.20", "age": 0.0},
]
class TestGetRouteTo:
def test_maps_protocols_and_drops_noise(self):
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to()
assert set(routes) == {"0.0.0.0/0", "10.0.0.0/24", "192.168.50.0/24", "2001:db8::/64"}
assert routes["0.0.0.0/0"][0]["protocol"] == "static"
assert routes["10.0.0.0/24"][0]["protocol"] == "connected"
assert routes["192.168.50.0/24"][0]["protocol"] == "dhcp"
assert routes["2001:db8::/64"][0]["protocol"] == "connected"
def test_entry_shape(self):
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to()
assert routes["192.168.50.0/24"] == [
{
"protocol": "dhcp",
"family": "ipv4",
"current_active": True,
"last_active": False,
"age": -1,
"next_hop": "10.0.0.254",
"outgoing_interface": "Ethernet",
"selected_next_hop": True,
"preference": 10,
"routing_table": "global",
"protocol_attributes": {},
}
]
def test_on_link_next_hop_is_empty(self):
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to()
assert routes["10.0.0.0/24"][0]["next_hop"] == ""
assert routes["2001:db8::/64"][0]["next_hop"] == ""
assert routes["2001:db8::/64"][0]["family"] == "ipv6"
def test_destination_filter(self):
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to(destination="0.0.0.0/0")
assert list(routes) == ["0.0.0.0/0"]
def test_protocol_filter(self):
routes = _driver(_PS_ROUTES=_fixture("routes.json")).get_route_to(protocol="dhcp")
assert list(routes) == ["192.168.50.0/24"]
class TestGetServices:
def test_maps_state_and_start_mode(self):
services = _driver(_PS_SERVICES=_fixture("services.json")).get_services()
assert services == [
{"name": "WinRM", "running": True, "enabled": True, "pid": 1234},
{"name": "Spooler", "running": False, "enabled": False, "pid": 0},
{"name": "MSSQL$SQLEXPRESS", "running": True, "enabled": True, "pid": 4321},
{"name": "RemoteRegistry", "running": False, "enabled": False, "pid": 0},
{"name": "wuauserv", "running": False, "enabled": False, "pid": 0},
]
class TestManageService:
@pytest.mark.parametrize(
("action", "command"),
[
("start", "Start-Service -Name 'Spooler'"),
("stop", "Stop-Service -Name 'Spooler'"),
("restart", "Restart-Service -Name 'Spooler'"),
("enable", "Set-Service -Name 'Spooler' -StartupType Automatic"),
("disable", "Set-Service -Name 'Spooler' -StartupType Disabled"),
],
)
def test_sends_the_matching_cmdlet(self, action, command):
d = _driver()
result = d.manage_service("Spooler", action)
assert result["success"] is True
assert command in d._transport.sent[0]
assert "-ErrorAction Stop" in d._transport.sent[0]
def test_service_name_with_dollar_is_valid(self):
d = _driver()
assert d.manage_service("MSSQL$SQLEXPRESS", "restart")["success"] is True
assert "'MSSQL$SQLEXPRESS'" in d._transport.sent[0]
def test_unknown_action_is_rejected(self):
with pytest.raises(ValueError, match="action"):
_driver().manage_service("Spooler", "reload")
@pytest.mark.parametrize("name", ["", "a'; Remove-Item C:\\ -Recurse", "a b", "a`b"])
def test_suspicious_name_is_rejected_before_anything_is_sent(self, name):
d = _driver()
with pytest.raises(ValueError, match="service name"):
d.manage_service(name, "start")
assert d._transport.sent == []
def test_powershell_error_is_reported_not_raised(self):
d = WindowsDriver("win01", "admin", "secret")
d._transport = FakeTransport(error="Cannot find any service with service name 'nope'.")
result = d.manage_service("nope", "start")
assert result == {
"success": False,
"output": "Cannot find any service with service name 'nope'.",
}
+67
View File
@@ -0,0 +1,67 @@
"""Record what the driver's PowerShell scripts emit on a real Windows host.
python tools/harvest.py <host> <user> <label> [--port 5985] [--insecure]
Asks for the password, runs every getter script over one WinRM connection and
writes the raw JSON to tools/harvest-out/<label>/<fixture>.json — the same file
names as tests/fixtures/synthetic/. harvest-out/ is gitignored: scrub serials,
MAC and IP addresses and host names before copying anything into tests/.
"""
from __future__ import annotations
import argparse
import getpass
import pathlib
from napalm_windows import WindowsDriver
from napalm_windows import windows as mod
SCRIPTS = {
"facts": mod._PS_FACTS,
"interfaces": mod._PS_INTERFACES,
"interfaces_ip": mod._PS_INTERFACES_IP,
"arp": mod._PS_ARP,
"routes": mod._PS_ROUTES,
"services": mod._PS_SERVICES,
}
OUT = pathlib.Path(__file__).parent / "harvest-out"
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("host")
ap.add_argument("user")
ap.add_argument("label", help="subdirectory name, e.g. server2022 or win11")
ap.add_argument("--port", type=int, default=5986)
ap.add_argument("--insecure", action="store_true", help="skip certificate validation")
args = ap.parse_args()
driver = WindowsDriver(
args.host,
args.user,
getpass.getpass(f"Password for {args.user}@{args.host}: "),
optional_args={"port": args.port, "ssl_verify": not args.insecure},
)
target = OUT / args.label
target.mkdir(parents=True, exist_ok=True)
driver.open()
try:
for name, script in SCRIPTS.items():
try:
raw = driver._run_raw(script)
except Exception as exc: # record the failure, keep harvesting
raw = ""
print(f" {name}: FAILED — {exc}")
else:
print(f" {name}: {len(raw)} bytes")
(target / f"{name}.json").write_text(raw + "\n")
finally:
driver.close()
print(f"written to {target}")
if __name__ == "__main__":
main()