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