Source code for pysnap.ssh.keys

"""The PySnap ssh key pair and per-VM ``known_hosts`` files.

Everything lives in ``~/.pysnap/ssh`` (mode 0700): the key pair
``pysnap_vm_access_ed25519`` with its ``.pub`` file, created once by
``ssh-keygen`` without a passphrase, and ``known_hosts.d/<VM UUID>``, one
``known_hosts`` file per VM. Both the directory and the file names are unique
to PySnap, and existing files are never overwritten.
"""

from __future__ import annotations

import os
from pathlib import Path
import re
import shutil
import socket
import subprocess

from pysnap.errors import PySnapError

KEY_NAME = "pysnap_vm_access_ed25519"
KEY_COMMENT_PREFIX = "pysnap-vm-access@"

_UUID_PATTERN = re.compile(r"^[0-9A-Za-z-]{1,64}$")


[docs] def find_executable(name: str) -> str: """Return the path of an OpenSSH program. :param name: ``ssh`` or ``ssh-keygen``. :returns: Executable path. :raises PySnapError: When the program is not installed. """ path = shutil.which(name) if path is None: raise PySnapError( f"The OpenSSH program {name} was not found. Install the OpenSSH client " "(it is part of Linux distributions, macOS and Windows 10 and newer)." ) return path
[docs] class SshKeyStore: """Locate and create the PySnap key pair and known_hosts files.""" def __init__(self, root: Path | None = None) -> None: """Initialize the store. :param root: Directory to use instead of ``~/.pysnap/ssh``. """ self.root = root or (Path.home() / ".pysnap" / "ssh")
[docs] @property def private_key(self) -> Path: """Return the private key path.""" return self.root / KEY_NAME
[docs] @property def public_key(self) -> Path: """Return the public key path.""" return self.root / f"{KEY_NAME}.pub"
[docs] def known_hosts(self, vm_uuid: str) -> Path: """Return the known_hosts file of one VM, creating its directory. :param vm_uuid: VirtualBox UUID of the VM. :returns: File path; the file itself is written by ssh. :raises PySnapError: For an unusable UUID. """ if not _UUID_PATTERN.match(vm_uuid): raise PySnapError(f'Invalid virtual machine UUID "{vm_uuid}".') directory = self._ensure_directory(self.root / "known_hosts.d") return directory / vm_uuid
[docs] def ensure_key(self) -> Path: """Return the private key, creating the key pair on first use. :returns: Private key path. :raises PySnapError: When only one half of the pair exists, when a path is taken by something else or ``ssh-keygen`` fails. """ self._ensure_directory(self.root) private_exists = os.path.lexists(self.private_key) public_exists = os.path.lexists(self.public_key) if private_exists and public_exists: for path in (self.private_key, self.public_key): if not path.is_file() or path.is_symlink(): raise PySnapError(f"{path} is not a regular file; PySnap does not use it.") return self.private_key if private_exists or public_exists: present = self.private_key if private_exists else self.public_key raise PySnapError( f"Only {present} exists; PySnap does not overwrite it. Move it away " "to let PySnap create a new key pair." ) comment = f"{KEY_COMMENT_PREFIX}{socket.gethostname() or 'host'}" result = subprocess.run( [find_executable("ssh-keygen"), "-q", "-t", "ed25519", "-N", "", "-C", comment, "-f", str(self.private_key)], stdin=subprocess.DEVNULL, capture_output=True, text=True, ) if result.returncode != 0: raise PySnapError(f"ssh-keygen failed: {result.stderr.strip() or result.returncode}") return self.private_key
[docs] def public_key_text(self) -> str: """Return the public key line, creating the key pair if needed. :returns: One ``ssh-ed25519 ... comment`` line. """ self.ensure_key() return self.public_key.read_text(encoding="ascii").strip()
@staticmethod def _ensure_directory(path: Path) -> Path: """Create a private directory or check an existing one.""" if os.path.lexists(path) and (path.is_symlink() or not path.is_dir()): raise PySnapError(f"{path} exists and is not a directory; PySnap does not use it.") path.mkdir(mode=0o700, parents=True, exist_ok=True) return path