Files
Christian Manivong 1ce42ef099 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
2026-09-24 09:23:25 +02:00

123 lines
4.2 KiB
Python

"""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