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
123 lines
4.2 KiB
Python
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
|