SSH Primitive (proxmox_sdk.ssh)¶
RemoteSSHClient is the single source of truth for SSH access used across the
proxmox-sdk workspace and its downstream consumers (notably proxbox-api's
hardware discovery flow). It is intentionally Proxmox-agnostic: nothing
about Proxmox VE, pvesh, or NetBox is baked in.
Where this lives. The class is exported as
from proxmox_sdk.ssh import RemoteSSHClient. Consumers must depend on this module rather than importingparamikodirectly.
Security model¶
Every constructor argument enforces a security property. None of these are opt-in.
| Property | Mechanism |
|---|---|
| Host-key pinning | known_host_fingerprint= (required). The SHA256 of the remote host key is compared via hashlib.sha256(key.asbytes()), base64-encoded with the SHA256: prefix. Mismatch raises HostKeyMismatch before auth. |
| No TOFU | A custom _RejectAlwaysPolicy is installed alongside the explicit fingerprint check. AutoAddPolicy and WarningPolicy are never used. |
| Algorithm pinning | The paramiko Transport's security options are rewritten before start_client() to prefer curve25519-sha256, ChaCha20-Poly1305 / AES-256-GCM, ETM SHA-512/256 MACs, and rsa-sha2-512/256 + ssh-ed25519 host keys. |
| argv-list only | run() accepts list[str] exclusively. Passing a str raises TypeError. Internally shlex.join() is used to form the remote command, so shell metacharacters in argv elements cannot escape word boundaries. |
| Optional allowlist | command_allowlist=["dmidecode", "ip", "ethtool"] refuses any other argv[0] with CommandNotAllowed. The basename of an absolute path is used for the check, so sudoers-pinned /usr/sbin/dmidecode still matches. |
| Bounded output | output_cap_bytes (default 256 KiB) caps combined stdout + stderr per run(). Overflow raises OutputTooLarge and closes the channel. |
| Bounded time | connect_timeout (default 10 s) bounds the TCP handshake explicitly via socket.create_connection; exec_timeout (default 30 s) bounds each run() call via asyncio.wait_for plus paramiko channel timeout. |
| Log redaction | _RedactingFilter is installed on the module logger. The default patterns cover password=…, token=…, BEGIN OPENSSH PRIVATE KEY blocks, and Fernet-shaped tokens. Override via log_redactors=. |
Construction¶
from proxmox_sdk.ssh import RemoteSSHClient, fingerprint_sha256
async with RemoteSSHClient(
host="10.0.0.1",
port=22,
username="proxbox-discovery",
known_host_fingerprint="SHA256:abc…", # required
private_key=client_key_pem, # OpenSSH PEM string
connect_timeout=10.0,
exec_timeout=30.0,
output_cap_bytes=256 * 1024,
command_allowlist=["dmidecode", "ip", "ethtool"],
) as ssh:
result = await ssh.run(["dmidecode", "-t", "1"])
print(result.stdout, result.exit_code)
Credentials are plaintext in memory only. Downstream consumers (proxbox-api, netbox-proxbox) store key material Fernet-encrypted at rest and decrypt just long enough to construct the client. Passphrase-protected keys are not supported here — keep the secret store as the only confidentiality boundary.
Fingerprint workflow¶
fingerprint_sha256(key) returns the canonical SHA256:<base64> form (no
trailing = padding) for either a paramiko.PKey instance or raw key bytes.
Use it to populate the pin in your credential store after a one-time admin
"trust on first connect" probe:
fp = fingerprint_sha256(remote_host_key_bytes)
# Persist `fp` only after explicit operator confirmation in the UI.
_normalize_fingerprint is applied to constructor input; it accepts the
canonical form, a lowercase sha256: prefix, padded base64, or a bare base64
value, and rejects MD5: (legacy/weak) and empty strings.
Exception hierarchy¶
SshError
├── HostKeyMismatch pinned fingerprint did not match
├── SshAuthFailed paramiko refused the supplied credentials
├── SshTimeout connect or exec timeout fired
├── OutputTooLarge stdout+stderr exceeded output_cap_bytes
└── CommandNotAllowed argv[0] outside command_allowlist
All five subclass SshError, so callers can pin a single except SshError if
they want to convert any failure into a warning frame.
CommandResult¶
run() returns a frozen dataclass:
@dataclass(frozen=True, slots=True)
class CommandResult:
argv: tuple[str, ...]
stdout: str
stderr: str
exit_code: int
duration_s: float
truncated: bool = False
truncated is reserved for future buffered-cap support; with the current
OutputTooLarge semantics it is always False on success.
Threading model¶
RemoteSSHClient is an async context manager. Internally it runs paramiko's
blocking primitives on the default executor via loop.run_in_executor,
bounded by asyncio.wait_for(..., timeout=...). Per-call timeouts mean a
stalled remote cannot strand executor threads beyond exec_timeout.
Testing¶
Two test files cover this module:
tests/test_remote_ssh_client.py— unit tests for argv validation, the allowlist, fingerprint normalisation, log redaction, andCommandResultshape. No socket is opened.tests/test_remote_ssh_client_integration.py— drives the real client against an in-processparamiko.ServerInterfacerunning in a background thread. Covers the happy path, fingerprint mismatch, output cap, and exec timeout. No external SSH server is required.
The integration test fixture is reusable as a pattern for any downstream project that wants to test against a known-good local SSH server.