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:
+13
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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/`.
|
||||
@@ -0,0 +1,5 @@
|
||||
"""NAPALM driver for Windows hosts via PowerShell Remoting (WinRM)."""
|
||||
|
||||
from napalm_windows.windows import WindowsDriver
|
||||
|
||||
__all__ = ["WindowsDriver"]
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
Vendored
+10
@@ -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.
|
||||
Vendored
+8
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
]
|
||||
Vendored
+10
@@ -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
@@ -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}
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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'.",
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user