Source code for netgear_switch.transport.cli.ssh
"""Paramiko-backed SSH CLI transport (implements ``CliSession``).
paramiko is an OPTIONAL dependency (the ``[ssh]`` extra) and is imported LAZILY
inside ``connect`` -- ``import netgear_switch`` never reaches paramiko, exactly
like the httpx transport under ``transport/http``.
PARAMIKO VERSION DECISION (documented as the reviewer asked)
------------------------------------------------------------
Old FASTPATH firmware (the GSM7252PS/M4300 generation) only offers the legacy
key exchange ``diffie-hellman-group14-sha1`` and the ``ssh-rsa`` (SHA-1) host-key
algorithm. paramiko 3.0 dropped both from its DEFAULT preferred lists (and later
releases removed some legacy SHA-1 primitives outright), so a stock modern
paramiko negotiates NOTHING with these switches and the handshake fails.
Two mitigations, applied together:
1. Pin the dependency to a release that still ships and prefers the legacy
algorithms -- ``paramiko>=2.12,<3`` (2.12 is CONFIRMED working against a real
GSM7252PS). This is the ``[ssh]`` extra's constraint in ``pyproject.toml``.
2. Belt-and-suspenders, ALSO re-insert the legacy algorithms into the
``Transport``'s preferred KEX / host-key lists explicitly here via
``get_security_options()`` when the running paramiko still defines them, so
the transport keeps working even if a newer paramiko is installed that
retains the primitives but merely de-prioritised them.
This transport CANNOT be live-tested from CI (no network); it is transport-only,
and the shared ``ShellDriver`` it builds on is unit-tested with a fake channel.
"""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
from .session import CliSession, CliTransportError, ShellDriver
if TYPE_CHECKING:
from ...protocols.cli.commands import CliModelSpec
# Legacy algorithms these old FASTPATH switches require (see module docstring).
_LEGACY_KEX = "diffie-hellman-group14-sha1"
_LEGACY_HOSTKEYS = ("ssh-rsa",)
_DEFAULT_TIMEOUT = 20.0
[docs]
class SshCliTransport(CliSession):
"""An SSH interactive-shell CLI session over paramiko."""
def __init__(
self,
host: str,
username: str,
password: str,
spec: CliModelSpec,
*,
port: int = 22,
timeout: float = _DEFAULT_TIMEOUT,
) -> None:
self._host = host
self._username = username
self._password = password
self._spec = spec
self._port = port
self._timeout = timeout
self._transport: Any = None
self._channel: Any = None
self._driver: ShellDriver | None = None
@staticmethod
def _prefer_legacy_algorithms(transport: Any) -> None:
"""Re-insert the legacy KEX/host-key algorithms this firmware needs.
Only touches algorithms the running paramiko still defines, so it is a
no-op (never an error) on a build that removed them entirely -- there the
version pin is what keeps things working.
"""
opts = transport.get_security_options()
available_kex = set(opts.kex)
if _LEGACY_KEX in available_kex:
opts.kex = (_LEGACY_KEX, *(k for k in opts.kex if k != _LEGACY_KEX))
available_keys = set(opts.key_types)
wanted_keys = tuple(k for k in _LEGACY_HOSTKEYS if k in available_keys)
if wanted_keys:
opts.key_types = (
*wanted_keys,
*(k for k in opts.key_types if k not in wanted_keys),
)
[docs]
def connect(self) -> None:
try:
import paramiko
except ImportError as exc: # pragma: no cover - depends on optional extra
raise CliTransportError(
"SSH CLI transport requires paramiko (install the '[ssh]' extra)"
) from exc
try:
transport = paramiko.Transport((self._host, self._port))
self._prefer_legacy_algorithms(transport)
transport.start_client(timeout=self._timeout)
transport.auth_password(self._username, self._password)
channel = transport.open_session(timeout=self._timeout)
channel.get_pty()
channel.invoke_shell()
channel.settimeout(self._timeout)
except Exception as exc:
self.close()
raise CliTransportError(f"SSH connect/auth failed: {exc}") from exc
self._transport = transport
self._channel = channel
self._driver = ShellDriver(
channel.sendall,
channel.recv,
enable_cmd=self._spec.enable_cmd,
paging_off_cmd=self._spec.paging_off_cmd,
enable_password=self._password,
)
self._driver.setup()
[docs]
def run(self, command: str) -> str:
if self._driver is None:
self.connect()
assert self._driver is not None
return self._driver.run(command)
[docs]
def run_scp_copy(self, command: str, scp_password: str) -> str:
if self._driver is None:
self.connect()
assert self._driver is not None
return self._driver.run_scp_copy(command, scp_password)
[docs]
def run_write_memory(self, command: str = "write memory", *, prestuff: bool) -> str:
if self._driver is None:
self.connect()
assert self._driver is not None
return self._driver.run_write_memory(command, prestuff=prestuff)
[docs]
def close(self) -> None:
if self._channel is not None:
with contextlib.suppress(Exception): # teardown must not raise
self._channel.close()
self._channel = None
if self._transport is not None:
with contextlib.suppress(Exception): # teardown must not raise
self._transport.close()
self._transport = None
self._driver = None