"""NAPALM driver for Proxmox VE. Supports: - Classic Linux networking (/etc/network/interfaces via Proxmox API) - Software-Defined Networking (SDN): zones, VNets, subnets - Open vSwitch (OVS) bridges, bonds, and internal ports Connection is made via the Proxmox REST API (``proxmoxer`` library). The driver targets the *node* level: each Proxmox node is treated as a network device. Cluster-wide SDN information is also exposed where the NAPALM API allows it. Optional args ------------- verify_ssl : bool Verify TLS certificates (default: True). port : int Proxmox API port (default: 8006). node : str Override the target node name (default: auto-detected from hostname). realm : str PAM realm (default: ``pam``). token_name : str API token name (e.g. ``napalm@pam!mytoken``). token_value : str API token secret. When both token_name and token_value are provided, token-based auth is used instead of password auth. """ from __future__ import annotations import logging from typing import Any logger = logging.getLogger(__name__) from napalm_device_types import FingerprintRule, HypervisorDriver, PortSpec from napalm.base.exceptions import ConnectionException try: from proxmoxer import ProxmoxAPI except ImportError as exc: raise ImportError( "proxmoxer is required: pip install proxmoxer" ) from exc from napalm_proxmox.interfaces_mixin import ProxmoxInterfaceMixin from napalm_proxmox.sdn_mixin import ProxmoxSDNMixin from napalm_proxmox.lldp_mixin import ProxmoxLLDPMixin from napalm_proxmox.config_mixin import ProxmoxConfigMixin from napalm_proxmox.vm_mixin import ProxmoxVMMixin from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin from napalm_proxmox.routing_mixin import ProxmoxRoutingMixin from napalm_proxmox.system_mixin import ProxmoxSystemMixin # --------------------------------------------------------------------------- # # Type aliases # --------------------------------------------------------------------------- # _JsonDict = dict[str, Any] # --------------------------------------------------------------------------- # # Driver # --------------------------------------------------------------------------- # class ProxmoxDriver( ProxmoxInterfaceMixin, ProxmoxSDNMixin, ProxmoxLLDPMixin, ProxmoxConfigMixin, ProxmoxVMMixin, ProxmoxVMProvisionMixin, ProxmoxRoutingMixin, ProxmoxSystemMixin, HypervisorDriver, ): """NAPALM driver for Proxmox VE nodes.""" VENDOR = "Proxmox" DRIVER_NAME = "proxmox" # Everything runs over the Proxmox REST API; there is no SSH session. USES_SSH = False # A PVE node reboots through a full init sequence plus storage checks. REBOOT_SETTLE_SECONDS = 90 PORT_SPECS = [ PortSpec("https", 8006, weight=8.0), ] SSH_FINGERPRINT = [ FingerprintRule("debian", weight=3.0), ] HTTP_FINGERPRINT = [ FingerprintRule("proxmox virtual environment", weight=9.0, mandatory=True), FingerprintRule("proxmox", weight=5.0), FingerprintRule("pve", weight=2.0), ] platform = "proxmox" def __init__( self, hostname: str, username: str, password: str, timeout: int = 60, optional_args: _JsonDict | None = None, ) -> None: self.hostname = hostname self.username = username self.password = password self.timeout = timeout self.optional_args: _JsonDict = optional_args or {} self._port: int = self.optional_args.get("port", 8006) self._verify_ssl: bool = self.optional_args.get( "verify_ssl", self.optional_args.get("ssl_verify", self.optional_args.get("verify", True)) ) self._realm: str = self.optional_args.get("realm", "pam") self._token_name: str | None = self.optional_args.get("token_name") self._token_value: str | None = self.optional_args.get("token_value") self._node: str | None = self.optional_args.get("node") self._ssh_username: str | None = self.optional_args.get("ssh_username") self._ssh_password: str | None = self.optional_args.get("ssh_password") self._ssh_key: str | None = self.optional_args.get("ssh_private_key_str") self._api: ProxmoxAPI | None = None self._node_name: str = "" self._ssh_client: "paramiko.SSHClient | None" = None # Candidate config (merge/replace) self._candidate_config: str = "" self._running_config: str = "" # ------------------------------------------------------------------ # # Connection management # ------------------------------------------------------------------ # def open(self) -> None: """Open the connection to the Proxmox API.""" try: # Use the HTTPS backend for proper REST API support. # The openssh backend tunnels all kwargs through to # openssh_wrapper.CommandBaseSession, which does not # accept password/verify_ssl/token params. # Proxmox authenticates against "@" and rejects a bare # username outright. A caller who typed a realm keeps it; one who # did not gets self._realm, which is what the documented `realm` # optional_arg is for -- it was read in __init__ and then never # used, so the option had no effect and a bare username failed. user = self.username or "" if user and "@" not in user: user = f"{user}@{self._realm}" kwargs: _JsonDict = { "host": self.hostname, "user": user, "password": self.password, "port": self._port, "verify_ssl": self._verify_ssl, "backend": "https", } if self._token_name and self._token_value: kwargs.pop("password", None) kwargs["token_value"] = self._token_value # token_name may be in "!" format (e.g. # "root@pam!netork"). proxmoxer expects them split: # user="root@pam", token_name="netork" user_part, _, token_id = self._token_name.partition("!") if token_id: kwargs["user"] = user_part kwargs["token_name"] = token_id else: kwargs["token_name"] = self._token_name self._api = ProxmoxAPI(**kwargs) # Validate connection by fetching node status self._resolve_node() except Exception as exc: raise ConnectionException( f"Cannot connect to {self.hostname}: {exc}" ) from exc def _resolve_node(self) -> str: """Resolve the PVE node this connection talks to. In a cluster, ``GET /nodes`` lists every member, so its first entry is just some node, not necessarily the one at ``self.hostname``. That used to be taken blindly, and a device then reported another node's name and VMs. ``GET /cluster/status`` marks the node the session landed on with ``local: 1``; failing that, the node is matched by IP or name. API errors propagate so that ``open()`` fails with the real cause (e.g. a TLS verification error) rather than succeeding on a guessed name. """ if self._node: self._node_name = self._node return self._node_name members = [ s for s in (self._api.cluster.status.get() or []) if s.get("type") == "node" ] short_host = self.hostname.split(".")[0] for pick in ( lambda s: s.get("local"), lambda s: s.get("ip") == self.hostname, lambda s: s.get("name") in (self.hostname, short_host), ): match = next((s for s in members if pick(s)), None) if match: self._node_name = match["name"] return self._node_name names = [n.get("node") for n in (self._api.nodes.get() or []) if n.get("node")] if len(names) == 1: self._node_name = names[0] return self._node_name raise ConnectionException( f"Cannot tell which PVE node {self.hostname} is among {names}; " f"set the 'node' driver argument" ) def close(self) -> None: """Close the connection.""" self._api = None if self._ssh_client: try: self._ssh_client.close() except Exception: pass self._ssh_client = None def is_alive(self) -> _JsonDict: """Return connection liveness. Probes ``GET /version``, the cheapest endpoint that proves the session still authenticates. It used to call ``_resolve_node()``, which returns early without touching the API whenever a node was configured via optional_args — so a dead connection reported itself alive. """ alive = False if self._api: try: self._api.version.get() alive = True except Exception: pass return {"is_alive": alive} # ------------------------------------------------------------------ # # Internal API helpers # ------------------------------------------------------------------ # def _node_api(self): """Return the API resource for the current node.""" return self._api.nodes(self._node_name) def _get_node_network(self) -> list[_JsonDict]: """Return the node's network interface list from the Proxmox API.""" try: return self._node_api().network.get() or [] except Exception as exc: logger.debug("Failed to fetch node network: %s", exc) return [] def _exec_ssh_command(self, command: str) -> str: """Execute a shell command on the Proxmox node and return output. Tries the Proxmox API exec endpoint first. If that fails, falls back to a direct paramiko SSH connection. """ import base64 as _b64 import time as _time # Try API exec endpoint try: encoded = _b64.b64encode(command.encode()).decode() result = self._node_api().execute.post("command", f"echo {encoded} | base64 -d | sh") # On PVE 8.x the exec endpoint returns a dict with 'data' key if isinstance(result, dict): raw = result.get("data", result.get("output", "")) else: raw = result if raw: return str(raw).strip() except Exception as exc: logger.debug("API exec failed, falling back to SSH: %s", exc) # Fallback: direct paramiko SSH try: import paramiko if self._ssh_client is None: ssh_user = self._ssh_username or self.username ssh_pass = self._ssh_password or self.password ssh_pkey = None if self._ssh_key and not ssh_pass: from io import StringIO as _StringIO ssh_pkey = paramiko.RSAKey.from_private_key(_StringIO(self._ssh_key)) self._ssh_client = paramiko.SSHClient() self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) connect_kwargs: _JsonDict = { "hostname": self.hostname, "port": 22, "username": ssh_user, "timeout": self.timeout, } if ssh_pkey: connect_kwargs["pkey"] = ssh_pkey else: connect_kwargs["password"] = ssh_pass self._ssh_client.connect(**connect_kwargs) _, stdout, stderr = self._ssh_client.exec_command(command, timeout=self.timeout) err = stderr.read().decode().strip() out = stdout.read().decode().strip() return out or err except ImportError: logger.warning("paramiko not installed — cannot exec SSH commands") except Exception as exc: logger.debug("SSH exec command failed: %s", exc) return "" # ------------------------------------------------------------------ # # Node info helpers # ------------------------------------------------------------------ # def _get_version_info(self) -> _JsonDict: """Return Proxmox VE version info from the API.""" try: return self._api.version.get() or {} except Exception as exc: logger.debug("Failed to fetch version info: %s", exc) return {} def _get_node_status(self) -> _JsonDict: """Return the node's status from the Proxmox API.""" try: return self._node_api().status.get() or {} except Exception as exc: logger.debug("Failed to fetch node status: %s", exc) return {} def _get_node_subscription(self) -> _JsonDict: """Return subscription status for this node.""" try: return self._node_api().subscription.get() or {} except Exception as exc: logger.debug("Failed to fetch node subscription: %s", exc) return {} def _get_node_dns(self) -> _JsonDict: """Return DNS configuration for this node.""" try: return self._node_api().dns.get() or {} except Exception as exc: logger.debug("Failed to fetch node DNS: %s", exc) return {} def _get_node_time(self) -> _JsonDict: """Return time configuration for this node.""" try: return self._node_api().time.get() or {} except Exception as exc: logger.debug("Failed to fetch node time: %s", exc) return {} def _get_node_ntp(self) -> _JsonDict: """Return NTP configuration for this node.""" try: return self._node_api().ntp.get() or {} except Exception as exc: logger.debug("Failed to fetch node NTP: %s", exc) return {} # ------------------------------------------------------------------ # # NAPALM getters kept in driver # ------------------------------------------------------------------ # def get_facts(self) -> _JsonDict: """Return basic facts about the Proxmox node. Hardware vendor, model and serial are read from the Linux DMI sysfs entries (``/sys/class/dmi/id/``) via SSH so they reflect the physical machine, not the Proxmox software layer. """ status = self._get_node_status() version = self._get_version_info() network = self._get_node_network() dns = self._get_node_dns() uptime = float(status.get("uptime", 0)) dns_search = dns.get("search", "") hostname = self._node_name fqdn = f"{self._node_name}.{dns_search}" if dns_search else self.hostname iface_list = sorted( iface["iface"] for iface in network if iface.get("iface") ) pve_version = version.get("version", "") release = version.get("release", "") os_version = f"Proxmox VE {pve_version}" if pve_version else f"Proxmox VE {release}" # Physical hardware info from Linux DMI sysfs. # Read each field separately to avoid shell quoting issues with printf. # Field priority for model: # product_name — human-readable name on most vendors (e.g. "NUC6CAYH", # "ThinkCentre M910x") # product_version — sometimes the marketing name on Lenovo; on Intel NUC # it is the board part number (less useful as model name) # We prefer product_name; fall back to product_version only when # product_name looks like a raw type code (all uppercase + digits, no spaces). vendor = "" model = "" serial = "" try: dmi_cmd = ( "v=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null); " "n=$(cat /sys/class/dmi/id/product_name 2>/dev/null); " "r=$(cat /sys/class/dmi/id/product_version 2>/dev/null); " "s=$(cat /sys/class/dmi/id/product_serial 2>/dev/null); " "printf '%s\\n%s\\n%s\\n%s\\n' \"$v\" \"$n\" \"$r\" \"$s\"" ) lines = self._exec_ssh_command(dmi_cmd).splitlines() if len(lines) >= 4: vendor = lines[0].strip() product_name = lines[1].strip() product_version = lines[2].strip() serial = lines[3].strip() _bad = {"none", "n/a", "not specified", "to be filled by o.e.m."} pv_usable = ( product_version and product_version.lower() not in _bad and product_version != product_name # Only prefer product_version when it contains a space — # that indicates a human-readable marketing name like # "ThinkCentre M910x" rather than a part code like "J26843-409". and " " in product_version ) model = product_version if pv_usable else product_name except Exception as exc: logger.debug("Failed to read DMI info via SSH: %s", exc) # Currently-booted kernel release (uname -r) — distinct from any newer # kernel that is merely installed and pending a reboot. running_kernel = "" try: running_kernel = self._exec_ssh_command("uname -r").strip() except Exception as exc: logger.debug("Failed to read running kernel via SSH: %s", exc) return { "uptime": uptime, "vendor": vendor or "Proxmox Server Solutions GmbH", "model": model or status.get("model") or "Proxmox VE Node", "hostname": self._node_name, "fqdn": fqdn or self.hostname, "os_version": os_version, "serial_number": serial, "interface_list": iface_list, "running_kernel": running_kernel, } # ------------------------------------------------------------------ # # CLI passthrough # ------------------------------------------------------------------ # def cli(self, commands: list[str], encoding: str = "text") -> dict[str, str]: """Execute a list of shell commands and return their outputs.""" return {cmd: self._exec_ssh_command(cmd) for cmd in commands}