Source code for pysnap.core.access
"""How PySnap reaches the guest of a VM: serial console or ssh.
Base VMs registered with ``pysnap protosettings`` and their clones are known
to have their console on COM1, so they are reached through the serial TCP
server of COM1. Every other VM is reached through ssh: its NAT adapter
forwards a host port on ``127.0.0.1`` to port 22 of the guest. The automatic
choice can be overridden per VM with ``pysnap set VM access=com|ssh``.
The override and the ssh user are kept in VirtualBox extra data of the VM;
the forwarded port is read from the NAT port-forwarding rule named
:data:`SSH_RULE_NAME`.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Collection
from pysnap.core.models import VMInfo
from pysnap.errors import PySnapError
ACCESS_KEY = "pysnap/access"
SSH_USER_KEY = "pysnap/ssh-user"
DEFAULT_SSH_USER = "root"
SSH_RULE_NAME = "pysnap-ssh"
SSH_HOST_ADDRESS = "127.0.0.1"
GUEST_SSH_PORT = 22
ACCESS_MODES = ("com", "ssh")
_SSH_USER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,31}$")
[docs]
@dataclass(frozen=True)
class AccessInfo:
"""Describe how PySnap reaches the guest of one VM.
:param mode: ``"com"`` for the COM1 serial console, ``"ssh"`` for ssh.
:param source: Why this mode applies: ``"override"`` (``pysnap set``),
``"protosettings"`` (the VM or its parent is registered) or
``"default"``.
:param serial_port: TCP port of COM1, if any.
:param ssh_port: Host port forwarded to the guest ssh server, if any.
:param ssh_user: User for ssh connections.
"""
mode: str
source: str
serial_port: int | None
ssh_port: int | None
ssh_user: str
[docs]
def resolve_access(info: VMInfo, proto_settings_vms: Collection[str]) -> AccessInfo:
"""Return the access mode and connection details of a VM.
:param info: VM information including PySnap metadata.
:param proto_settings_vms: Base VM names registered with protosettings.
:returns: Access description.
"""
override = info.metadata.get(ACCESS_KEY, "").strip().lower()
if override in ACCESS_MODES:
mode, source = override, "override"
elif info.name in proto_settings_vms or (
info.parent_name is not None and info.parent_name in proto_settings_vms
):
mode, source = "com", "protosettings"
else:
mode, source = "ssh", "default"
return AccessInfo(
mode=mode,
source=source,
serial_port=info.serial_port,
ssh_port=info.ssh_port,
ssh_user=info.metadata.get(SSH_USER_KEY) or DEFAULT_SSH_USER,
)
[docs]
def validate_access_mode(value: str) -> str:
"""Validate an ``access=`` value.
:param value: ``com``, ``ssh`` or ``auto``.
:returns: The lower-case value.
:raises PySnapError: For other values.
"""
lowered = value.strip().lower()
if lowered not in (*ACCESS_MODES, "auto"):
raise PySnapError(f'access: invalid value "{value}"; use com, ssh or auto.')
return lowered
[docs]
def validate_ssh_user(value: str) -> str:
"""Validate an ``ssh-user=`` value.
:param value: User name in the guest.
:returns: The user name.
:raises PySnapError: For names that are not plain user names.
"""
if not _SSH_USER_PATTERN.match(value):
raise PySnapError(
f'ssh-user: invalid user name "{value}"; use letters, digits, "_", "." or "-".'
)
return value
[docs]
def ssh_forwarding_rule(port: int) -> str:
"""Return the VirtualBox NAT rule that forwards a host port to ssh.
:param port: Host port on :data:`SSH_HOST_ADDRESS`.
:returns: Rule in ``VBoxManage --natpfN`` syntax.
"""
return f"{SSH_RULE_NAME},tcp,{SSH_HOST_ADDRESS},{port},,{GUEST_SSH_PORT}"
[docs]
def parse_ssh_forwarding(rule: str) -> int | None:
"""Return the host port of a PySnap ssh forwarding rule.
:param rule: Rule as printed by ``showvminfo --machinereadable``,
``name,protocol,host address,host port,guest address,guest port``.
:returns: Host port, or ``None`` for other rules.
"""
parts = [part.strip() for part in rule.split(",")]
if len(parts) != 6 or parts[0] != SSH_RULE_NAME or parts[1].lower() != "tcp":
return None
return int(parts[3]) if parts[3].isdigit() else None