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:
@@ -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
|
||||
Reference in New Issue
Block a user