Files
napalm-proxmox/napalm_proxmox/driver.py
T
Christian ManivongandClaude Sonnet 4.6 5732a4494e feat: detect missing lldpd and add install_lldpd action
get_device_warnings() now reports lldpd_not_installed (action
install_lldpd) when lldpd is absent, matching the existing
OpenWrt pattern. run_device_action("install_lldpd") installs and
enables lldpd via apt/systemd so LLDP topology links to directly
connected switches can be discovered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 08:34:18 +02:00

2424 lines
94 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
import re
import socket
from typing import Any
logger = logging.getLogger(__name__)
from napalm_device_types import HypervisorDriver
from napalm.base.exceptions import (
ConnectionException,
SessionLockedException,
)
from napalm.base.helpers import mac as napalm_mac
from napalm.base.netmiko_helpers import netmiko_args
import napalm.base.constants as C
try:
from proxmoxer import ProxmoxAPI
from proxmoxer.core import ResourceException
except ImportError as exc: # pragma: no cover
raise ImportError(
"proxmoxer is required: pip install proxmoxer"
) from exc
from napalm_proxmox import utils
# --------------------------------------------------------------------------- #
# Type aliases
# --------------------------------------------------------------------------- #
_JsonDict = dict[str, Any]
# --------------------------------------------------------------------------- #
# Driver
# --------------------------------------------------------------------------- #
class ProxmoxDriver(HypervisorDriver):
"""NAPALM driver for Proxmox VE nodes."""
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._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:
kwargs: _JsonDict = {
"host": self.hostname,
"port": self._port,
"verify_ssl": self._verify_ssl,
"timeout": self.timeout,
}
if self._token_name and self._token_value:
# Token-based auth: token_name format is "user@realm!tokenid"
# proxmoxer needs user="user@realm", token_name="tokenid", token_value="..."
if "!" in self._token_name:
user_part, token_id = self._token_name.split("!", 1)
else:
user_part = self.username or "root@pam"
token_id = self._token_name
kwargs["user"] = user_part
kwargs["token_name"] = token_id
kwargs["token_value"] = self._token_value
else:
kwargs["user"] = f"{self.username}@{self._realm}"
kwargs["password"] = self.password
self._api = ProxmoxAPI(**kwargs)
# Determine which node we're talking to
self._node_name = self._resolve_node()
except Exception as exc:
raise ConnectionException(
f"Cannot connect to Proxmox at {self.hostname}:{self._port} — {exc}"
) from exc
# Establish a persistent SSH connection for shell commands (used by
# _exec_ssh_command as a fast path, avoiding per-call connect overhead).
if self.username and self.password:
try:
import paramiko # noqa: PLC0415
ssh_port: int = self.optional_args.get("ssh_port", 22)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
hostname=self.hostname,
port=ssh_port,
username=self.username,
password=self.password,
timeout=self.timeout,
look_for_keys=False,
allow_agent=False,
)
self._ssh_client = client
except Exception:
self._ssh_client = None
def _resolve_node(self) -> str:
"""Return the Proxmox node name for this host."""
if self._node:
return self._node
# Try to match hostname against listed nodes
try:
nodes = self._api.nodes.get() # type: ignore[union-attr]
except Exception:
nodes = []
short_host = self.hostname.split(".")[0].lower()
for node_entry in nodes:
name = node_entry.get("node", "")
if name.lower() == short_host or name.lower() == self.hostname.lower():
return name
# Fall back to first online node
for node_entry in nodes:
if node_entry.get("status") == "online":
return node_entry["node"]
# Last resort: use the shortened hostname
return short_host
def close(self) -> None:
"""Close the session (Proxmox REST is stateless; nothing to tear down)."""
self._api = None
if self._ssh_client is not None:
try:
self._ssh_client.close()
except Exception:
pass
self._ssh_client = None
def is_alive(self) -> _JsonDict:
"""Return connection state."""
try:
self._api.version.get() # type: ignore[union-attr]
return {"is_alive": True}
except Exception:
return {"is_alive": False}
# ------------------------------------------------------------------ #
# Internal helpers
# ------------------------------------------------------------------ #
def _node_api(self):
"""Return the proxmoxer sub-resource for the target node."""
return self._api.nodes(self._node_name) # type: ignore[union-attr]
def _get_node_network(self) -> list[_JsonDict]:
"""Return the list of network interfaces from the Proxmox node API."""
try:
return self._node_api().network.get() or []
except ResourceException:
return []
def _exec_ssh_command(self, command: str) -> str:
"""Execute a shell command on the Proxmox node.
First tries the Proxmox API execute endpoint. If that fails (e.g.
because token-auth is blocked on that endpoint), falls back to SSH
using the driver's username / password credentials.
Falls back to an empty string if both methods are unavailable.
"""
# 1. Try Proxmox API execute endpoint
try:
result = self._node_api().execute.post(command=command)
data = result.get("data", "")
if isinstance(data, str):
return data
except Exception:
pass
# 2. Fall back to SSH using explicit SSH credentials from optional_args,
# or the driver's own username/password as a last resort.
ssh_user = self.optional_args.get("ssh_username") or self.username
ssh_pass = self.optional_args.get("ssh_password") or self.password
ssh_key_str: str | None = self.optional_args.get("ssh_private_key_str")
if not ssh_user or (not ssh_pass and not ssh_key_str):
return ""
try:
import paramiko # noqa: PLC0415
import tempfile, os as _os # noqa: PLC0415
client = self._ssh_client
if client is None or not client.get_transport() or not client.get_transport().is_active():
ssh_port: int = self.optional_args.get("ssh_port", 22)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs: dict = dict(
hostname=self.hostname,
port=ssh_port,
username=ssh_user,
timeout=self.timeout,
look_for_keys=False,
allow_agent=False,
)
_tmp_key = None
if ssh_key_str:
_tmp_key = tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False)
_tmp_key.write(ssh_key_str)
_tmp_key.flush()
_tmp_key.close()
connect_kwargs["key_filename"] = _tmp_key.name
else:
connect_kwargs["password"] = ssh_pass
try:
client.connect(**connect_kwargs)
finally:
if _tmp_key:
try:
_os.unlink(_tmp_key.name)
except OSError:
pass
self._ssh_client = client
_, stdout, _ = client.exec_command(command, timeout=self.timeout)
output = stdout.read().decode("utf-8", errors="replace")
return output
except Exception:
self._ssh_client = None
return ""
def _get_version_info(self) -> _JsonDict:
"""Return Proxmox version dict."""
try:
return self._api.version.get() or {} # type: ignore[union-attr]
except Exception:
return {}
def _get_node_status(self) -> _JsonDict:
"""Return node status dict."""
try:
return self._node_api().status.get() or {}
except Exception:
return {}
def _get_node_subscription(self) -> _JsonDict:
try:
return self._node_api().subscription.get() or {}
except Exception:
return {}
def _get_sdn_zones(self) -> list[_JsonDict]:
try:
return self._api.cluster.sdn.zones.get() or [] # type: ignore[union-attr]
except Exception:
return []
def _get_sdn_vnets(self) -> list[_JsonDict]:
try:
return self._api.cluster.sdn.vnets.get() or [] # type: ignore[union-attr]
except Exception:
return []
def _get_sdn_subnets(self, vnet: str) -> list[_JsonDict]:
try:
return self._api.cluster.sdn.vnets(vnet).subnets.get() or []
except Exception:
return []
def _get_node_dns(self) -> _JsonDict:
try:
return self._node_api().dns.get() or {}
except Exception:
return {}
def _get_node_time(self) -> _JsonDict:
try:
return self._node_api().time.get() or {}
except Exception:
return {}
def _get_node_ntp(self) -> _JsonDict:
try:
return self._node_api().ntp.get() or {}
except Exception:
return {}
# ------------------------------------------------------------------ #
# get_facts
# ------------------------------------------------------------------ #
def get_facts(self) -> _JsonDict:
"""Return basic facts about the Proxmox node."""
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))
model = status.get("model", "")
# dns["search"] is the DNS search domain (e.g. "home.example.com"), not the hostname
dns_search = dns.get("search", "")
hostname = self._node_name
fqdn = f"{self._node_name}.{dns_search}" if dns_search else self.hostname
# Build interface list
iface_list = sorted(
iface["iface"] for iface in network if iface.get("iface")
)
# PVE version looks like "8.2.4"
pve_version = version.get("version", "")
release = version.get("release", "")
os_version = f"Proxmox VE {pve_version}" if pve_version else f"Proxmox VE {release}"
return {
"uptime": uptime,
"vendor": "Proxmox Server Solutions GmbH",
"model": model or "Proxmox VE Node",
"hostname": self._node_name,
"fqdn": fqdn or self.hostname,
"os_version": os_version,
"serial_number": "",
"interface_list": iface_list,
}
# ------------------------------------------------------------------ #
# get_interfaces
# ------------------------------------------------------------------ #
def get_interfaces(self) -> dict[str, _JsonDict]:
"""Return a dict of interfaces keyed by interface name."""
result: dict[str, _JsonDict] = {}
for iface in self._get_node_network():
name = iface.get("iface", "")
if not name:
continue
# Proxmox marks bridge ports as not independent — include all
active = iface.get("active", 0)
autostart = iface.get("autostart", 0)
result[name] = {
"is_up": bool(active),
"is_enabled": bool(autostart) or bool(active),
"description": iface.get("comments", "").strip(),
"last_flapped": -1.0,
"speed": utils.speed_mbps(iface),
"mtu": int(iface.get("mtu") or 1500),
"mac_address": utils.normalize_mac(iface.get("hwaddr", "")),
}
return result
# ------------------------------------------------------------------ #
# get_interfaces_ip
# ------------------------------------------------------------------ #
def get_interfaces_ip(self) -> dict[str, _JsonDict]:
"""Return IP addresses per interface."""
result: dict[str, _JsonDict] = {}
for iface in self._get_node_network():
name = iface.get("iface", "")
if not name:
continue
addrs = utils.addresses_from_node_network(iface)
if addrs:
result[name] = addrs
# Overlay SDN VNet addresses (subnets with gateways)
for vnet in self._get_sdn_vnets():
vnet_id = vnet.get("vnet", "")
if not vnet_id:
continue
for subnet in self._get_sdn_subnets(vnet_id):
cidr = subnet.get("cidr", "")
gateway = subnet.get("gateway", "")
if gateway and cidr:
ip, plen = utils.parse_cidr(cidr)
if "." in gateway:
result.setdefault(vnet_id, {}).setdefault("ipv4", {})[gateway] = {
"prefix_length": plen
}
else:
result.setdefault(vnet_id, {}).setdefault("ipv6", {})[gateway] = {
"prefix_length": plen
}
return result
# ------------------------------------------------------------------ #
# get_interfaces_counters
# ------------------------------------------------------------------ #
def get_interfaces_counters(self) -> dict[str, _JsonDict]:
"""Return per-interface traffic counters."""
result: dict[str, _JsonDict] = {}
try:
rrd_data = self._node_api().netstat.get() or []
except Exception:
rrd_data = []
# Proxmox /nodes/{node}/netstat returns a list of time-series points.
# Take the most recent (last) entry for each interface.
latest: dict[str, _JsonDict] = {}
for entry in rrd_data:
iface = entry.get("dev", "")
if iface:
latest[iface] = entry
for iface, data in latest.items():
result[iface] = {
"tx_errors": int(data.get("tx_errs", 0) or 0),
"rx_errors": int(data.get("rx_errs", 0) or 0),
"tx_discards": int(data.get("tx_drop", 0) or 0),
"rx_discards": int(data.get("rx_drop", 0) or 0),
"tx_octets": int(data.get("tx_bytes", 0) or 0),
"rx_octets": int(data.get("rx_bytes", 0) or 0),
"tx_unicast_packets": int(data.get("tx_packets", 0) or 0),
"rx_unicast_packets": int(data.get("rx_packets", 0) or 0),
"tx_multicast_packets": 0,
"rx_multicast_packets": 0,
"tx_broadcast_packets": 0,
"rx_broadcast_packets": 0,
}
return result
# ------------------------------------------------------------------ #
# get_environment
# ------------------------------------------------------------------ #
def get_environment(self) -> _JsonDict:
"""Return environment status (CPU, memory, temperature)."""
status = self._get_node_status()
env: _JsonDict = {
"fans": {},
"temperature": {},
"power": {},
"cpu": {},
"memory": {"available_ram": 0, "used_ram": 0},
}
# CPU
cpu_usage = status.get("cpu", 0.0)
env["cpu"]["0"] = {"%usage": round(float(cpu_usage) * 100, 2)}
# Memory (Proxmox reports in bytes)
mem = status.get("memory", {})
total = int(mem.get("total", 0) or 0)
used = int(mem.get("used", 0) or 0)
env["memory"]["available_ram"] = total
env["memory"]["used_ram"] = used
# Temperature (from node sensors if available)
try:
sensors = self._node_api().hardware.sensors.get() or []
except Exception:
sensors = []
for sensor in sensors:
name = sensor.get("name", "unknown")
value = sensor.get("value", None)
if value is not None:
try:
temp_c = float(value)
env["temperature"][name] = {
"temperature": temp_c,
"is_alert": temp_c >= 80.0,
"is_critical": temp_c >= 95.0,
}
except (TypeError, ValueError):
pass
return env
# ------------------------------------------------------------------ #
# get_arp_table
# ------------------------------------------------------------------ #
def get_arp_table(self, vrf: str = "") -> list[_JsonDict]:
"""Return ARP table.
Proxmox does not expose ARP via the REST API directly. We attempt
to read it via the node's ``/proc/net/arp`` through the Proxmox
exec endpoint. If that is unavailable, an empty list is returned.
"""
raw = self._exec_ssh_command("cat /proc/net/arp")
if not raw:
return []
entries = []
# /proc/net/arp format:
# IP address HW type Flags HW address Mask Device
# 192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("IP"):
continue
parts = line.split()
if len(parts) < 6:
continue
ip_addr, _, flags, mac, _, iface = (
parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]
)
if mac in ("00:00:00:00:00:00", ""):
continue
if vrf and iface != vrf:
continue
entries.append(
{
"interface": iface,
"mac": utils.normalize_mac(mac),
"ip": ip_addr,
"age": -1.0,
}
)
return entries
# ------------------------------------------------------------------ #
# get_mac_address_table
# ------------------------------------------------------------------ #
def get_mac_address_table(self) -> list[_JsonDict]:
"""Return MAC address table from Linux bridges and OVS bridges."""
result: list[_JsonDict] = []
network = self._get_node_network()
# Linux bridges
linux_bridges = [
iface["iface"]
for iface in network
if iface.get("type") in ("bridge",) and iface.get("iface")
]
for bridge in linux_bridges:
raw = self._exec_ssh_command(
f"bridge fdb show br {bridge} 2>/dev/null || true"
)
for line in raw.splitlines():
parts = line.split()
if len(parts) < 3:
continue
mac_str = parts[0]
if not re.match(r"([0-9a-f]{2}:){5}[0-9a-f]{2}", mac_str):
continue
# "dev <port>" "vlan <vid>"
dev = ""
vlan_id = 1
for i, tok in enumerate(parts):
if tok == "dev" and i + 1 < len(parts):
dev = parts[i + 1]
if tok == "vlan" and i + 1 < len(parts):
try:
vlan_id = int(parts[i + 1])
except ValueError:
pass
result.append(
{
"mac": utils.normalize_mac(mac_str),
"interface": dev or bridge,
"vlan": vlan_id,
"static": "permanent" in line,
"active": True,
"moves": 0,
"last_move": 0.0,
}
)
# OVS bridges
ovs_bridges = [
iface["iface"]
for iface in network
if iface.get("type") in ("OVSBridge",) and iface.get("iface")
]
for bridge in ovs_bridges:
raw = self._exec_ssh_command(
f"ovs-appctl fdb/show {bridge} 2>/dev/null || true"
)
# Format: port VLAN MAC AGE
for line in raw.splitlines():
parts = line.split()
if len(parts) < 4:
continue
try:
_port = int(parts[0])
vlan_id = int(parts[1])
mac_str = parts[2]
except (ValueError, IndexError):
continue
result.append(
{
"mac": utils.normalize_mac(mac_str),
"interface": bridge,
"vlan": vlan_id,
"static": False,
"active": True,
"moves": 0,
"last_move": 0.0,
}
)
return result
# ------------------------------------------------------------------ #
# get_vlans (SDN VNets + classic Linux bridge VLANs)
# ------------------------------------------------------------------ #
def get_vlans(self) -> dict[str, _JsonDict]:
"""Return VLAN table.
For OVS+SDN nodes: reads SDN VNets for VLAN IDs/names, then maps
OVSIntPort (access ports with ovs_tag) → untagged membership, and
OVSPort / OVSBridge (trunk ports) → tagged membership.
Falls back to ``bridge vlan show`` for classic Linux-bridge nodes.
"""
result: dict[str, _JsonDict] = {}
node_network = self._get_node_network()
# --- SDN vnets → VLAN IDs and initial entries ---
for vnet in self._get_sdn_vnets():
tag = vnet.get("tag")
vnet_id = vnet.get("vnet", "")
if tag is None:
continue
try:
tag_int = int(tag)
except (ValueError, TypeError):
continue
result[str(tag_int)] = {
"name": vnet_id,
"tagged": [],
"untagged": [],
}
# --- OVS port membership ---
trunk_ports: list[str] = []
access_by_vlan: dict[str, list[str]] = {}
for iface in node_network:
ovs_type = iface.get("ovs_type", "")
iface_name = iface.get("iface", "")
if not iface_name:
continue
if ovs_type == "OVSIntPort":
ovs_tag = iface.get("ovs_tag")
if ovs_tag is not None:
vid = str(int(ovs_tag))
access_by_vlan.setdefault(vid, []).append(iface_name)
elif ovs_type in ("OVSPort", "OVSBridge"):
# Trunk: carries all VLANs tagged
trunk_ports.append(iface_name)
if trunk_ports or access_by_vlan:
# OVS topology detected — assign tagged/untagged per VLAN
for vid, vlan_entry in result.items():
vlan_entry["tagged"] = list(trunk_ports)
vlan_entry["untagged"] = list(access_by_vlan.get(vid, []))
return result
# --- Linux bridge fallback (bridge vlan show) ---
# Convert result entries to use tagged/untagged keys
for entry in result.values():
entry.setdefault("tagged", [])
entry.setdefault("untagged", [])
raw = self._exec_ssh_command("bridge vlan show 2>/dev/null || true")
current_iface = ""
for line in raw.splitlines():
line = line.strip()
if not line:
continue
m = re.match(r"^(\S+)\s+(\d+)", line)
if m:
current_iface = m.group(1)
vid = m.group(2)
else:
m2 = re.match(r"^\s*(\d+)", line)
if m2:
vid = m2.group(1)
else:
continue
if current_iface and vid:
entry = result.setdefault(vid, {"name": "", "tagged": [], "untagged": []})
if "Untagged" in line or "PVID" in line:
if current_iface not in entry["untagged"]:
entry["untagged"].append(current_iface)
else:
if current_iface not in entry["tagged"]:
entry["tagged"].append(current_iface)
# Only return VLANs that are actually assigned to at least one interface.
# Linux bridge vlan show reports all 4094 possible VIDs per port —
# filtering here avoids polluting the VLAN table with phantom entries.
return {
vid: entry for vid, entry in result.items()
if entry.get("tagged") or entry.get("untagged")
}
# ------------------------------------------------------------------ #
# get_network_instances (SDN Zones as VRF-like instances)
# ------------------------------------------------------------------ #
def get_network_instances(self, name: str = "") -> dict[str, _JsonDict]:
"""Return SDN zones as network instances."""
result: dict[str, _JsonDict] = {}
# Always include the default instance
result["default"] = {
"name": "default",
"type": "DEFAULT_INSTANCE",
"state": {"route_distinguisher": None},
"interfaces": {"interface": {}},
}
# Attach non-SDN interfaces to the default instance
for iface in self._get_node_network():
iface_name = iface.get("iface", "")
if iface_name:
result["default"]["interfaces"]["interface"][iface_name] = {}
# SDN Zones
for zone in self._get_sdn_zones():
zone_id = zone.get("zone", zone.get("name", ""))
if not zone_id:
continue
if name and zone_id != name:
continue
instance = utils.sdn_zone_to_network_instance(zone)
# Attach VNets that belong to this zone
for vnet in self._get_sdn_vnets():
if vnet.get("zone") == zone_id:
vnet_id = vnet.get("vnet", "")
if vnet_id:
instance["interfaces"]["interface"][vnet_id] = {}
result[zone_id] = instance
if name:
return {k: v for k, v in result.items() if k == name}
return result
# ------------------------------------------------------------------ #
# get_ntp_servers / get_ntp_stats
# ------------------------------------------------------------------ #
def get_ntp_servers(self) -> dict[str, _JsonDict]:
"""Return configured NTP servers."""
ntp = self._get_node_ntp()
servers: dict[str, _JsonDict] = {}
# Proxmox reports a comma-separated or space-separated server list
raw = ntp.get("server", "") or ntp.get("servers", "")
for srv in re.split(r"[\s,]+", raw):
srv = srv.strip()
if srv:
servers[srv] = {}
return servers
def get_ntp_stats(self) -> list[_JsonDict]:
"""Return NTP synchronisation statistics from chronyc/ntpq output."""
raw = self._exec_ssh_command(
"chronyc -n tracking 2>/dev/null || ntpq -pn 2>/dev/null || true"
)
stats: list[_JsonDict] = []
for line in raw.splitlines():
line = line.strip()
# ntpq -pn format: *remote refid st t when poll reach delay offset jitter
m = re.match(
r"^([\*\+\-\s])([\d.]+)\s+([\d.]+)\s+(\d+)\s+\S+\s+(\S+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+([-\d.]+)\s+([\d.]+)",
line,
)
if m:
synced = m.group(1).strip() == "*"
stats.append(
{
"remote": m.group(2),
"referenceid": m.group(3),
"synchronized": synced,
"stratum": int(m.group(4)),
"type": "",
"when": m.group(5),
"hostpoll": int(m.group(6)),
"reachability": int(m.group(7)),
"delay": float(m.group(8)),
"offset": float(m.group(9)),
"jitter": float(m.group(10)),
}
)
return stats
# ------------------------------------------------------------------ #
# get_snmp_information
# ------------------------------------------------------------------ #
def get_snmp_information(self) -> _JsonDict:
"""Return SNMP information.
Proxmox does not expose SNMP configuration via the REST API.
We read /etc/snmp/snmpd.conf via exec if available.
"""
raw = self._exec_ssh_command(
"cat /etc/snmp/snmpd.conf 2>/dev/null || true"
)
communities: dict[str, _JsonDict] = {}
location = ""
contact = ""
for line in raw.splitlines():
line = line.strip()
if line.startswith("#") or not line:
continue
# rocommunity <community> [source]
m = re.match(r"^(ro|rw)community\s+(\S+)", line)
if m:
mode = "ro" if m.group(1) == "ro" else "rw"
community = m.group(2)
communities[community] = {"acl": "N/A", "mode": mode}
m_loc = re.match(r"^sysLocation\s+(.+)", line)
if m_loc:
location = m_loc.group(1).strip()
m_con = re.match(r"^sysContact\s+(.+)", line)
if m_con:
contact = m_con.group(1).strip()
return {
"chassis_id": self._node_name,
"community": communities,
"contact": contact,
"location": location,
}
# ------------------------------------------------------------------ #
# get_users
# ------------------------------------------------------------------ #
def get_users(self) -> dict[str, _JsonDict]:
"""Return users configured on the Proxmox node.
Reads from both the Proxmox access/users API and local /etc/passwd.
"""
result: dict[str, _JsonDict] = {}
try:
pve_users = self._api.access.users.get() or [] # type: ignore[union-attr]
except Exception:
pve_users = []
for user in pve_users:
uid = user.get("userid", "")
if not uid:
continue
# Proxmox roles: Administrator → 15, otherwise 1
groups = user.get("groups", "") or ""
level = 1
try:
roles = self._api.access.users(uid).get() or {} # type: ignore[union-attr]
if "Administrator" in str(roles):
level = 15
except Exception:
pass
result[uid] = {
"level": level,
"password": "",
"sshkeys": [],
}
# Merge local OS users from /etc/passwd
raw = self._exec_ssh_command("getent passwd 2>/dev/null || cat /etc/passwd")
for line in raw.splitlines():
parts = line.split(":")
if len(parts) < 7:
continue
uname, _, uid_str, *_ = parts
try:
uid_int = int(uid_str)
except ValueError:
continue
if uname not in result and uid_int < 1000 or uid_int == 0:
result[uname] = {
"level": 15 if uid_int == 0 else 0,
"password": "",
"sshkeys": [],
}
return result
# ------------------------------------------------------------------ #
# get_config
# ------------------------------------------------------------------ #
def get_config(
self,
retrieve: str = "all",
full: bool = False,
sanitized: bool = False,
format: str = "text",
) -> _JsonDict:
"""Return the node network configuration.
``running`` config is the contents of ``/etc/network/interfaces``
(and the SDN config directory). ``startup`` is identical (PVE
applies on boot). ``candidate`` is what was loaded via
``load_merge_candidate`` / ``load_replace_candidate`` but not yet
committed.
"""
configs: _JsonDict = {"running": "", "candidate": "", "startup": ""}
if retrieve in ("running", "all", "startup"):
raw = self._exec_ssh_command(
"cat /etc/network/interfaces 2>/dev/null || true"
)
# Append SDN config if available
sdn_raw = self._exec_ssh_command(
"cat /etc/pve/sdn/vnets.cfg 2>/dev/null || true"
)
running = raw
if sdn_raw:
running += "\n# === SDN VNets ===\n" + sdn_raw
if sanitized:
running = re.sub(r"password\s+\S+", "password ****", running)
configs["running"] = running
self._running_config = running
if retrieve == "startup":
configs["startup"] = running
elif retrieve == "all":
configs["startup"] = running
if retrieve in ("candidate", "all"):
configs["candidate"] = self._candidate_config
return configs
# ------------------------------------------------------------------ #
# Config management (load / compare / commit / discard / rollback)
# ------------------------------------------------------------------ #
def load_merge_candidate(
self,
filename: str | None = None,
config: str | None = None,
) -> None:
"""Load a candidate configuration (merge mode)."""
if filename:
with open(filename) as fh:
config = fh.read()
if config is None:
raise ValueError("Either filename or config must be provided")
# In merge mode we append / overlay
self._candidate_config = config
def load_replace_candidate(
self,
filename: str | None = None,
config: str | None = None,
) -> None:
"""Load a candidate configuration (replace mode)."""
if filename:
with open(filename) as fh:
config = fh.read()
if config is None:
raise ValueError("Either filename or config must be provided")
self._candidate_config = config
def compare_config(self) -> str:
"""Return a unified diff between running and candidate config."""
import difflib
if not self._running_config:
self.get_config(retrieve="running")
running_lines = self._running_config.splitlines(keepends=True)
candidate_lines = self._candidate_config.splitlines(keepends=True)
diff = difflib.unified_diff(
running_lines,
candidate_lines,
fromfile="running",
tofile="candidate",
)
return "".join(diff)
def commit_config(self, message: str = "", revert_in: int | None = None) -> None:
"""Commit the candidate configuration to the Proxmox node.
This writes the candidate config to ``/etc/network/interfaces``
via the Proxmox node/network PUT API (which applies it live).
.. note::
Full programmatic apply requires the Proxmox API to accept raw
interface configs. This implementation uses ``pvesh`` via exec
which requires the node exec endpoint to be available.
"""
if not self._candidate_config:
return
# Write via exec endpoint
escaped = self._candidate_config.replace("'", "'\\''")
self._exec_ssh_command(
f"printf '%s' '{escaped}' > /etc/network/interfaces && "
"ifreload -a 2>&1 || ifup -a 2>&1 || true"
)
self._running_config = self._candidate_config
self._candidate_config = ""
def discard_config(self) -> None:
"""Discard the candidate configuration."""
self._candidate_config = ""
def rollback(self) -> None:
"""Revert to the stored running configuration."""
if self._running_config:
self._candidate_config = self._running_config
self.commit_config()
self._candidate_config = ""
# ------------------------------------------------------------------ #
# get_lldp_neighbors (via lldpcli if installed)
# ------------------------------------------------------------------ #
def get_lldp_neighbors(self) -> dict[str, list[_JsonDict]]:
"""Return LLDP neighbours (requires lldpd on the Proxmox node)."""
result: dict[str, list[_JsonDict]] = {}
raw = self._exec_ssh_command(
"lldpcli show neighbors summary 2>/dev/null || true"
)
current_iface = ""
for line in raw.splitlines():
m_iface = re.match(r"^\s*Interface:\s+(\S+?),?\s", line)
if m_iface:
current_iface = m_iface.group(1)
result.setdefault(current_iface, [])
continue
m_sys = re.match(r"^\s*SysName:\s+(.+)", line)
m_port = re.match(r"^\s*PortID:\s+\S+\s+(.+)", line)
if m_sys and current_iface:
hostname = m_sys.group(1).strip()
if result[current_iface]:
result[current_iface][-1]["hostname"] = hostname
else:
result[current_iface].append({"hostname": hostname, "port": ""})
if m_port and current_iface and result[current_iface]:
result[current_iface][-1]["port"] = m_port.group(1).strip()
return result
def get_lldp_neighbors_detail(self, interface: str = "") -> dict[str, list[_JsonDict]]:
"""Return detailed LLDP neighbour information."""
result: dict[str, list[_JsonDict]] = {}
raw = self._exec_ssh_command(
"lldpcli show neighbors details 2>/dev/null || true"
)
current_iface = ""
current_entry: _JsonDict = {}
def _flush():
if current_iface and current_entry:
result.setdefault(current_iface, []).append(current_entry.copy())
for line in raw.splitlines():
m_iface = re.match(r"^\s*Interface:\s+(\S+?),?\s", line)
if m_iface:
_flush()
current_iface = m_iface.group(1)
if interface and current_iface != interface:
current_iface = ""
current_entry = {
"parent_interface": "",
"remote_chassis_id": "",
"remote_system_name": "",
"remote_port": "",
"remote_port_description": "",
"remote_system_description": "",
"remote_system_capab": [],
"remote_system_enable_capab": [],
}
continue
if not current_iface:
continue
for key, pattern in (
("remote_chassis_id", r"ChassisID:\s+\S+\s+(.+)"),
("remote_system_name", r"SysName:\s+(.+)"),
("remote_port", r"PortID:\s+\S+\s+(.+)"),
("remote_port_description", r"PortDescr:\s+(.+)"),
("remote_system_description", r"SysDescr:\s+(.+)"),
):
m = re.match(rf"^\s*{pattern}", line)
if m:
current_entry[key] = m.group(1).strip()
m_cap = re.match(r"^\s*Capability:\s+(\S+),\s+(\w+)", line)
if m_cap:
cap = m_cap.group(1).lower()
enabled = m_cap.group(2).lower() == "on"
current_entry["remote_system_capab"].append(cap)
if enabled:
current_entry["remote_system_enable_capab"].append(cap)
_flush()
return result
# ------------------------------------------------------------------ #
# get_ipv6_neighbors_table
# ------------------------------------------------------------------ #
def get_ipv6_neighbors_table(self) -> list[_JsonDict]:
"""Return IPv6 NDP neighbour table."""
raw = self._exec_ssh_command("ip -6 neigh show 2>/dev/null || true")
result = []
for line in raw.splitlines():
parts = line.split()
# Format: <ip> dev <iface> lladdr <mac> <state>
if len(parts) < 5:
continue
ip6 = parts[0]
iface = parts[2] if len(parts) > 2 else ""
mac = ""
state = ""
for i, tok in enumerate(parts):
if tok == "lladdr" and i + 1 < len(parts):
mac = parts[i + 1]
if tok in ("REACHABLE", "STALE", "DELAY", "PROBE", "FAILED", "NOARP", "PERMANENT"):
state = tok
if not mac or mac == "FAILED":
continue
result.append(
{
"interface": iface,
"mac": utils.normalize_mac(mac),
"ip": ip6,
"age": -1.0,
"state": state,
}
)
return result
# ------------------------------------------------------------------ #
# ping (via Proxmox node/execute)
# ------------------------------------------------------------------ #
def ping(
self,
destination: str,
source: str = C.PING_SOURCE,
ttl: int = C.PING_TTL,
timeout: int = C.PING_TIMEOUT,
size: int = C.PING_SIZE,
count: int = C.PING_COUNT,
vrf: str = C.PING_VRF,
source_interface: str = C.PING_SOURCE_INTERFACE,
) -> _JsonDict:
"""Execute ping on the Proxmox node and return results."""
cmd_parts = [
f"ping -c {count}",
f"-W {timeout}",
f"-s {size}",
f"-t {ttl}",
]
if source:
cmd_parts.append(f"-I {source}")
elif source_interface:
cmd_parts.append(f"-I {source_interface}")
cmd_parts.append(destination)
cmd = " ".join(cmd_parts)
raw = self._exec_ssh_command(f"{cmd} 2>&1 || true")
if not raw:
return {"error": "Ping command not available via exec endpoint"}
# Detect common failure strings before parsing statistics
_error_patterns = (
"Name or service not known",
"Network is unreachable",
"connect: No route to host",
"unknown host",
)
for _pat in _error_patterns:
if _pat.lower() in raw.lower():
return {"error": raw.strip()}
# Parse statistics line: "5 packets transmitted, 5 received, 0% packet loss"
m_stat = re.search(
r"(\d+) packets transmitted,\s+(\d+) received.*?([\d.]+)% packet loss",
raw,
)
if not m_stat:
return {"error": raw.strip()}
sent = int(m_stat.group(1))
received = int(m_stat.group(2))
loss = sent - received
# RTT line: "rtt min/avg/max/mdev = 0.123/0.456/0.789/0.100 ms"
m_rtt = re.search(
r"rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)",
raw,
)
rtt_min = float(m_rtt.group(1)) if m_rtt else 0.0
rtt_avg = float(m_rtt.group(2)) if m_rtt else 0.0
rtt_max = float(m_rtt.group(3)) if m_rtt else 0.0
rtt_std = float(m_rtt.group(4)) if m_rtt else 0.0
# Individual probe lines
probes = []
for m_probe in re.finditer(
r"icmp_seq=\d+.*?time=([\d.]+) ms.*?from ([\d.a-fA-F:]+)", raw
):
probes.append(
{"ip_address": m_probe.group(2), "rtt": float(m_probe.group(1))}
)
return {
"success": {
"probes_sent": sent,
"packet_loss": loss,
"rtt_min": rtt_min,
"rtt_max": rtt_max,
"rtt_avg": rtt_avg,
"rtt_stddev": rtt_std,
"results": probes,
}
}
# ------------------------------------------------------------------ #
# traceroute
# ------------------------------------------------------------------ #
def traceroute(
self,
destination: str,
source: str = "",
ttl: int = 255,
timeout: int = 2,
vrf: str = "",
) -> _JsonDict:
"""Execute traceroute on the Proxmox node and return results."""
cmd_parts = [f"traceroute -m {ttl}", f"-w {timeout}", "-n"]
if source:
cmd_parts.append(f"-s {source}")
cmd_parts.append(destination)
raw = self._exec_ssh_command(" ".join(cmd_parts) + " 2>&1 || true")
if not raw:
return {"error": "traceroute not available via exec endpoint"}
hops: _JsonDict = {}
for line in raw.splitlines():
m = re.match(
r"^\s*(\d+)\s+([\d.a-fA-F:]+|\*)\s+([\d.]+|[\d.]+\s+ms|\*)",
line,
)
if not m:
continue
hop_id = int(m.group(1))
ip_addr = m.group(2)
if ip_addr == "*":
continue
# Parse RTT probes: each hop can have up to 3
rtts = re.findall(r"([\d.]+)\s+ms", line)
probes_dict = {}
for idx, rtt in enumerate(rtts, start=1):
probes_dict[idx] = {
"rtt": float(rtt),
"ip_address": ip_addr,
"host_name": ip_addr,
}
if probes_dict:
hops[hop_id] = {"probes": probes_dict}
if not hops:
return {"error": raw.strip()}
return {"success": hops}
# ------------------------------------------------------------------ #
# cli
# ------------------------------------------------------------------ #
def cli(self, commands: list[str], encoding: str = "text") -> dict[str, str]:
"""Execute arbitrary commands on the node via the exec endpoint."""
output = {}
for cmd in commands:
output[cmd] = self._exec_ssh_command(cmd)
return output
# ------------------------------------------------------------------ #
# Unsupported / not-applicable methods
# ------------------------------------------------------------------ #
def get_bgp_config(self, group: str = "", neighbor: str = "") -> _JsonDict:
raise NotImplementedError("BGP configuration is not managed via Proxmox API")
def get_bgp_neighbors(self) -> _JsonDict:
raise NotImplementedError("BGP is not managed via Proxmox API")
def get_bgp_neighbors_detail(self, neighbor_address: str = "") -> _JsonDict:
raise NotImplementedError("BGP is not managed via Proxmox API")
def get_route_to(
self, destination: str = "", protocol: str = "", longer: bool = False
) -> _JsonDict:
"""Return routing table entries for the given destination."""
cmd = f"ip route show {destination} 2>/dev/null || true"
raw = self._exec_ssh_command(cmd)
routes: _JsonDict = {}
for line in raw.splitlines():
line = line.strip()
if not line:
continue
parts = line.split()
if not parts:
continue
prefix = parts[0]
next_hop = ""
out_iface = ""
proto = "static"
for i, tok in enumerate(parts):
if tok == "via" and i + 1 < len(parts):
next_hop = parts[i + 1]
if tok == "dev" and i + 1 < len(parts):
out_iface = parts[i + 1]
if tok == "proto" and i + 1 < len(parts):
proto = parts[i + 1]
if protocol and protocol.lower() not in proto.lower():
continue
routes.setdefault(prefix, []).append(
{
"protocol": proto,
"current_active": True,
"last_active": True,
"age": -1,
"next_hop": next_hop,
"outgoing_interface": out_iface,
"selected_next_hop": True,
"preference": 1,
"inactive_reason": "",
"routing_table": "default",
"protocol_attributes": {},
}
)
return routes
def get_optics(self) -> _JsonDict:
raise NotImplementedError("Optics not available via Proxmox API")
def get_probes_config(self) -> _JsonDict:
raise NotImplementedError
def get_probes_results(self) -> _JsonDict:
raise NotImplementedError
def get_firewall_policies(self) -> _JsonDict:
raise NotImplementedError("Use the Proxmox firewall API directly")
# ------------------------------------------------------------------ #
# VMs and Containers
# ------------------------------------------------------------------ #
def get_vm_interfaces(
self, vmid: int, vm_type: str
) -> tuple[dict[str, _JsonDict], bool, bool]:
"""Return network interfaces for a single VM or LXC container.
Returns a 3-tuple ``(interfaces, agent_running, agent_enabled)``:
* ``interfaces`` – dict keyed by interface name, each with
NAPALM-compatible fields plus ``ipv4``, ``bridge``, and ``tag``.
* ``agent_running`` – True if the QEMU Guest Agent responded during
this call (always False for LXC).
* ``agent_enabled`` – True if the QEMU Guest Agent is enabled in the
VM's Proxmox config (always False for LXC).
LXC : uses ``/nodes/{node}/lxc/{vmid}/interfaces`` + LXC config
QEMU : tries QEMU guest agent first, falls back to VM config parsing.
In both paths the VM config is fetched to derive bridge/tag.
"""
_NET_MODELS = {"virtio", "e1000", "e1000e", "vmxnet3", "rtl8139", "ne2k_pci"}
def _parse_net_entry(val_str: str) -> tuple[str, str, int | None]:
"""Parse a Proxmox net config value → (mac_upper, bridge, tag|None)."""
mac = bridge = ""
tag: int | None = None
for part in str(val_str).split(","):
if "=" not in part:
continue
k, v = part.split("=", 1)
k = k.strip().lower()
if k in _NET_MODELS:
mac = v.strip()
elif k == "bridge":
bridge = v.strip()
elif k == "tag":
try:
tag = int(v.strip())
except ValueError:
pass
return mac.upper() if mac else "", bridge, tag
interfaces: dict[str, _JsonDict] = {}
if vm_type == "container":
# Build iface_name → (bridge, tag) from LXC config
# LXC net entries look like: net0=name=eth0,bridge=vmbr40,tag=40,...
lxc_net_map: dict[str, tuple[str, int | None]] = {}
try:
config = self._node_api().lxc(vmid).config.get() or {}
net_re = re.compile(r"^net(\d+)$")
for key, val in config.items():
if not net_re.match(key):
continue
iface_name = ""
bridge = ""
tag: int | None = None
for part in str(val).split(","):
if "=" not in part:
continue
k, v = part.split("=", 1)
k = k.strip().lower()
if k == "name":
iface_name = v.strip()
elif k == "bridge":
bridge = v.strip()
elif k == "tag":
try:
tag = int(v.strip())
except ValueError:
pass
if iface_name:
lxc_net_map[iface_name] = (bridge, tag)
except Exception as exc:
logger.debug("get_vm_interfaces: LXC %s config failed: %s", vmid, exc)
try:
for iface in (self._node_api().lxc(vmid).interfaces.get() or []):
name = iface.get("name", "")
if not name or name == "lo":
continue
mac = iface.get("hwaddr", "")
ipv4 = ""
inet = iface.get("inet", "")
if inet:
ipv4 = inet.split("/")[0]
bridge, tag = lxc_net_map.get(name, ("", None))
interfaces[name] = {
"is_up": True,
"is_enabled": True,
"description": bridge,
"mac_address": mac.upper() if mac else "",
"speed": -1,
"mtu": 1500,
"last_flapped": -1.0,
"ipv4": ipv4,
"bridge": bridge,
"tag": tag,
}
except Exception as exc:
logger.debug("get_vm_interfaces: LXC %s ifaces failed: %s", vmid, exc)
# LXC containers do not use QEMU Guest Agent
return interfaces, False, False
else:
# QEMU: pre-fetch VM config to build MAC → (bridge, tag) map and
# to check whether the QEMU Guest Agent is enabled.
mac_to_net: dict[str, tuple[str, int | None]] = {} # mac_upper → (bridge, tag)
net_idx_map: dict[str, tuple[str, str, int | None]] = {} # "netN" → (mac, bridge, tag)
agent_enabled = False
try:
config = self._node_api().qemu(vmid).config.get() or {}
# Proxmox stores the agent setting as agent=1, agent=0, or
# agent=enabled=1[,fstrim_cloned_disks=1,...]
raw_agent = str(config.get("agent", "0"))
# Treat any truthy value ("1", "enabled=1", ...) as enabled
agent_enabled = bool(
raw_agent.strip() in ("1", "true")
or raw_agent.startswith("enabled=1")
or raw_agent.startswith("1,")
)
net_re = re.compile(r"^net(\d+)$")
for key, val in config.items():
m = net_re.match(key)
if not m:
continue
mac, bridge, tag = _parse_net_entry(val)
iface_key = f"net{m.group(1)}"
net_idx_map[iface_key] = (mac, bridge, tag)
if mac:
mac_to_net[mac] = (bridge, tag)
except Exception as exc:
logger.debug("get_vm_interfaces: QEMU %s config fetch failed: %s", vmid, exc)
# Try guest agent first
agent_ok = False
try:
agent_result = self._node_api().qemu(vmid).agent("network-get-interfaces").get()
for iface in (agent_result or {}).get("result", []):
name = iface.get("name", "")
if not name or name == "lo":
continue
mac = (iface.get("hardware-address", "") or "").upper()
ipv4 = ""
for addr in iface.get("ip-addresses", []):
if addr.get("ip-address-type") == "ipv4":
ipv4 = addr.get("ip-address", "")
break
bridge, tag = mac_to_net.get(mac, ("", None))
interfaces[name] = {
"is_up": True,
"is_enabled": True,
"description": bridge,
"mac_address": mac,
"speed": -1,
"mtu": 1500,
"last_flapped": -1.0,
"ipv4": ipv4,
"bridge": bridge,
"tag": tag,
}
agent_ok = bool(interfaces)
except Exception:
pass
if not agent_ok:
# Fall back to config-only (gives MAC + bridge + tag, no IP)
for iface_key, (mac, bridge, tag) in net_idx_map.items():
interfaces[iface_key] = {
"is_up": False,
"is_enabled": True,
"description": bridge,
"mac_address": mac,
"speed": -1,
"mtu": 1500,
"last_flapped": -1.0,
"ipv4": "",
"bridge": bridge,
"tag": tag,
}
return interfaces, agent_ok, agent_enabled
def get_vms(self) -> list[_JsonDict]:
"""Return all VMs (QEMU) and containers (LXC) on this node.
Each entry contains:
* vmid (int) - Proxmox VM/container ID
* name (str) - display name
* type (str) - ``"vm"`` or ``"container"``
* status (str) - ``"running"``, ``"stopped"``, etc.
* vcpus (int) - allocated vCPUs
* memory (int) - configured RAM in megabytes
* cpu_usage (float) - current CPU utilisation 0.0–1.0 (from last stats cycle)
* memory_usage (int)- current RSS in megabytes
* uptime (int) - uptime in seconds (0 if stopped)
* node (str) - cluster node name
* interfaces (dict) - network interfaces (NAPALM format + ipv4 field)
* ipv4 (str) - primary IPv4 address (empty string if unknown)
"""
result: list[_JsonDict] = []
# QEMU VMs
try:
for vm in (self._node_api().qemu.get() or []):
vmid = int(vm.get("vmid", 0))
name = vm.get("name", f"vm-{vmid}")
status = vm.get("status", "unknown")
cpu_usage = float(vm.get("cpu", 0.0) or 0.0)
uptime = int(vm.get("uptime", 0) or 0)
# mem/maxmem are in bytes
maxmem_bytes = int(vm.get("maxmem", 0) or 0)
mem_bytes = int(vm.get("mem", 0) or 0)
memory_mb = maxmem_bytes // (1024 * 1024)
memory_usage_mb = mem_bytes // (1024 * 1024)
# vcpus can be in "cpus" key for running VMs
vcpus = int(vm.get("cpus", vm.get("vcpus", 0)) or 0)
interfaces, agent_running, agent_enabled = self.get_vm_interfaces(vmid, "vm")
ipv4 = next(
(iface["ipv4"] for iface in interfaces.values() if iface.get("ipv4")),
"",
)
result.append({
"vmid": vmid,
"name": name,
"type": "vm",
"status": status,
"vcpus": vcpus,
"memory": memory_mb,
"cpu_usage": round(cpu_usage, 4),
"memory_usage": memory_usage_mb,
"uptime": uptime,
"node": self._node_name,
"interfaces": interfaces,
"ipv4": ipv4,
"agent_enabled": agent_enabled,
"agent_running": agent_running,
})
except Exception as exc:
logger.warning("get_vms: failed to list QEMU VMs: %s", exc)
# LXC containers
try:
for ct in (self._node_api().lxc.get() or []):
vmid = int(ct.get("vmid", 0))
name = ct.get("name", f"ct-{vmid}")
status = ct.get("status", "unknown")
cpu_usage = float(ct.get("cpu", 0.0) or 0.0)
uptime = int(ct.get("uptime", 0) or 0)
maxmem_bytes = int(ct.get("maxmem", 0) or 0)
mem_bytes = int(ct.get("mem", 0) or 0)
memory_mb = maxmem_bytes // (1024 * 1024)
memory_usage_mb = mem_bytes // (1024 * 1024)
vcpus = int(ct.get("cpus", 0) or 0)
interfaces, agent_running, agent_enabled = self.get_vm_interfaces(vmid, "container")
ipv4 = next(
(iface["ipv4"] for iface in interfaces.values() if iface.get("ipv4")),
"",
)
result.append({
"vmid": vmid,
"name": name,
"type": "container",
"status": status,
"vcpus": vcpus,
"memory": memory_mb,
"cpu_usage": round(cpu_usage, 4),
"memory_usage": memory_usage_mb,
"uptime": uptime,
"node": self._node_name,
"interfaces": interfaces,
"ipv4": ipv4,
"agent_enabled": agent_enabled,
"agent_running": agent_running,
})
except Exception as exc:
logger.warning("get_vms: failed to list LXC containers: %s", exc)
return sorted(result, key=lambda x: x["vmid"])
# ------------------------------------------------------------------ #
# VM power management
# ------------------------------------------------------------------ #
_POWER_ACTIONS_VM = {'start', 'stop', 'shutdown', 'reboot', 'reset'}
_POWER_ACTIONS_CT = {'start', 'stop', 'shutdown', 'reboot'}
def power_vm(self, vmid: int, vm_type: str, action: str) -> dict:
"""Send a power action to a VM or container on this node.
Supported actions for VMs: start, stop, shutdown, reboot, reset
Supported actions for containers: start, stop, shutdown, reboot
"""
allowed = self._POWER_ACTIONS_VM if vm_type == 'vm' else self._POWER_ACTIONS_CT
if action not in allowed:
return {"success": False, "error": f"Action '{action}' not supported for {vm_type} (allowed: {sorted(allowed)})"}
try:
vm_api = self._node_api().qemu(vmid) if vm_type == 'vm' else self._node_api().lxc(vmid)
task_id = getattr(vm_api.status, action).post()
return {"success": True, "task_id": task_id or ""}
except Exception as exc:
return {"success": False, "error": str(exc)}
# ------------------------------------------------------------------ #
# Packages (Debian APT)
# ------------------------------------------------------------------ #
def get_packages(self) -> list[_JsonDict]:
"""Return installed Debian packages with available-update info.
Installed list comes from ``dpkg-query`` via SSH (the Proxmox API
``/apt/installed`` endpoint is not implemented on PVE 8.x).
Available updates come from the Proxmox API ``/apt/update``.
"""
# Available updates from Proxmox API (keyed by package name)
upgradable: dict[str, str] = {}
try:
for upd in self._api.nodes(self._node_name).apt.update.get():
pkg = upd.get("Package", "")
if pkg:
upgradable[pkg] = upd.get("Version", "")
except Exception:
pass
# Installed packages via SSH dpkg-query
raw = self._exec_ssh_command(
"dpkg-query -W -f='${Package}\\t${Version}\\t${db:Status-Status}\\t${Installed-Size}\\n'"
" 2>/dev/null"
)
result: list[_JsonDict] = []
for line in raw.splitlines():
parts = line.strip().split("\t")
if len(parts) < 2:
continue
name = parts[0]
version = parts[1] if len(parts) > 1 else ""
status = parts[2] if len(parts) > 2 else "installed"
size_kb = parts[3] if len(parts) > 3 else "0"
if not name or status != "installed":
continue
size_bytes = int(size_kb) * 1024 if size_kb.isdigit() else 0
result.append({
"name": name,
"version": version,
"installed": True,
"description": "",
"size": size_bytes,
"source": "pve",
"upgrade_version": upgradable.get(name, ""),
})
return result
# ------------------------------------------------------------------ #
# Device warnings
# ------------------------------------------------------------------ #
def get_device_warnings(self) -> list[_JsonDict]:
"""Return warnings for the Proxmox node.
Currently detects:
- lldpd not installed (LLDP neighbor discovery unavailable)
- Available package updates (via Proxmox APT API)
- Missing / invalid subscription
"""
warnings: list[_JsonDict] = []
# 0. LLDP daemon
try:
lldpd_path = self._exec_ssh_command("command -v lldpd 2>/dev/null").strip()
if not lldpd_path:
warnings.append({
"code": "lldpd_not_installed",
"severity": "warning",
"action": "install_lldpd",
})
except Exception:
pass
# 1. Available package updates
try:
updates = self._api.nodes(self._node_name).apt.update.get()
if updates:
warnings.append({
"code": "updates_available",
"severity": "info",
"action": None,
"meta": {
"count": len(updates),
"packages": [u.get("Package", "") for u in updates[:10]],
},
})
except Exception:
pass
# 2. Subscription status
try:
sub = self._get_node_subscription()
status = sub.get("status", "")
if status in ("NotFound", "Invalid", "Expired"):
warnings.append({
"code": "no_subscription",
"severity": "warning",
"action": None,
"meta": {"status": status},
})
except Exception:
pass
return warnings
# ------------------------------------------------------------------ #
# Services (systemd)
# ------------------------------------------------------------------ #
def get_services(self) -> list[_JsonDict]:
"""Return systemd services with running and enabled state.
Uses two ``systemctl`` invocations combined in a single SSH command:
- ``list-unit-files`` for the static enabled/disabled state
- ``list-units`` for the live running state
"""
raw = self._exec_ssh_command(
"{ systemctl list-unit-files --type=service --no-pager --no-legend --full 2>/dev/null;"
" echo '---UNITS---';"
" systemctl list-units --type=service --all --no-pager --no-legend --full 2>/dev/null;"
" } || true"
)
# Parse enabled state from list-unit-files
enabled_map: dict[str, bool] = {}
section = "files"
for line in raw.splitlines():
if line.strip() == "---UNITS---":
section = "units"
continue
parts = line.strip().split(None, 1)
if len(parts) < 1:
continue
unit = parts[0].lstrip("●").strip()
if not unit.endswith(".service"):
continue
name = unit[: -len(".service")]
if section == "files":
state = parts[1].strip() if len(parts) > 1 else ""
enabled_map[name] = state in ("enabled", "enabled-runtime", "static")
# Parse running state from list-units
running_map: dict[str, bool] = {}
section = "files"
for line in raw.splitlines():
if line.strip() == "---UNITS---":
section = "units"
continue
if section != "units":
continue
parts = line.strip().lstrip("●").strip().split(None, 4)
if len(parts) < 4:
continue
unit = parts[0]
if not unit.endswith(".service"):
continue
name = unit[: -len(".service")]
sub_state = parts[3]
running_map[name] = sub_state == "running"
all_names = sorted(set(enabled_map) | set(running_map))
return [
{
"name": name,
"running": running_map.get(name, False),
"enabled": enabled_map.get(name, False),
"pid": 0,
}
for name in all_names
]
def manage_service(self, name: str, action: str) -> _JsonDict:
"""Start / stop / restart / enable / disable a systemd service."""
if not re.match(r'^[a-zA-Z0-9_\-\.@]+$', name):
raise ValueError(f"Invalid service name: {name!r}")
if action not in ('start', 'stop', 'restart', 'enable', 'disable'):
raise ValueError(f"Invalid action: {action!r}")
output = self._exec_ssh_command(f"systemctl {action} {name}.service 2>&1 || true")
return {"success": True, "output": output}
# ------------------------------------------------------------------ #
# Available updates
# ------------------------------------------------------------------ #
def get_available_updates(self) -> list[_JsonDict]:
"""Return list of upgradable packages from the Proxmox APT API."""
updates: list[_JsonDict] = []
try:
for upd in self._api.nodes(self._node_name).apt.update.get():
pkg = upd.get("Package", "")
if not pkg:
continue
updates.append({
"name": pkg,
"current_version": upd.get("OldVersion", ""),
"new_version": upd.get("Version", ""),
})
except Exception:
pass
return sorted(updates, key=lambda u: u["name"])
def apply_updates(self, packages: list[str]) -> _JsonDict:
"""Upgrade the given packages via ``apt-get install`` over SSH."""
for pkg in packages:
if not re.match(r'^[a-zA-Z0-9_\-\+\.]+$', pkg):
raise ValueError(f"Invalid package name: {pkg!r}")
pkg_args = " ".join(packages)
output = self._exec_ssh_command(
f"DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y {pkg_args} 2>&1 || true"
)
return {"success": True, "output": output}
# ------------------------------------------------------------------ #
# VLAN provisioning via SDN VNets (OVS-backed nodes only)
# ------------------------------------------------------------------ #
# Proxmox-internal / runtime virtual interface name prefixes that should
# never be considered physical switch uplinks.
_VIRTUAL_IFACE_PREFIXES = (
"fwpr", # Proxmox firewall proxy veth
"fwln", # Proxmox firewall line veth
"tap", # VM tap devices
"veth", # generic veth pairs
"virbr", # libvirt bridges
"docker", # Docker virtual interfaces
"lxcbr", # LXC bridges
)
def _is_physical_uplink(self, iface_name: str, network: dict) -> bool:
"""Return True if *iface_name* is a physical Ethernet port usable as uplink.
Rules:
- Must not match any known virtual interface name prefix.
- Must appear in the Proxmox node network config (runtime-only virtual
interfaces such as ``fwpr*`` or ``tap*`` will not be listed there).
- Must have a physical-compatible type:
- ``"eth"`` — regular physical NIC
- ``"OVSPort"`` — physical NIC attached directly to an OVS bridge
- ``""`` — untyped (e.g. OVS bond slave, still physical)
"""
if any(iface_name.startswith(p) for p in self._VIRTUAL_IFACE_PREFIXES):
return False
iface_info = network.get(iface_name)
if iface_info is None:
# Not in Proxmox network config → runtime virtual interface
return False
return iface_info.get("type", "") in ("eth", "OVSPort", "")
def _find_switch_uplink(self) -> str | None:
"""Return the name of the physical interface connected to a switch.
Detection order:
1. LLDP detailed: physical port whose neighbour advertises Bridge
capability.
2. LLDP basic fallback: first physical port with any LLDP neighbour.
"""
network = {
iface["iface"]: iface
for iface in self._get_node_network()
if iface.get("iface")
}
# Prefer neighbours that announce Bridge capability
try:
for iface_name, neighbour_list in self.get_lldp_neighbors_detail().items():
if not self._is_physical_uplink(iface_name, network):
continue
for nb in neighbour_list:
caps = nb.get("remote_system_capab", [])
if any("bridge" in str(c).lower() for c in caps):
return iface_name
except Exception:
pass
# Fallback: first physical port with any LLDP neighbour
try:
for iface_name, neighbour_list in self.get_lldp_neighbors().items():
if self._is_physical_uplink(iface_name, network) and neighbour_list:
return iface_name
except Exception:
pass
return None
def _get_ovs_bridge_for_port(self, port_name: str) -> str | None:
"""Return the OVS bridge name that *port_name* belongs to, or ``None``.
Checks (in order):
1. Port listed in an OVSBridge's ``ovs_ports``.
2. Port is a slave of an OVSBond which has an ``ovs_bridge`` reference.
3. Port itself carries an ``ovs_bridge`` field.
"""
network = self._get_node_network()
by_name: dict[str, _JsonDict] = {
iface["iface"]: iface for iface in network if iface.get("iface")
}
for iface in network:
if iface.get("type") == "OVSBridge":
ports = (iface.get("ovs_ports") or "").split()
if port_name in ports:
return iface["iface"]
for iface in network:
if iface.get("type") == "OVSBond":
slaves = (iface.get("slaves") or "").split()
if port_name in slaves:
bridge = iface.get("ovs_bridge", "")
if bridge:
return bridge
port_info = by_name.get(port_name, {})
return port_info.get("ovs_bridge") or None
def _is_cluster_master(self) -> bool:
"""Return ``True`` if this node is the Corosync quorum coordinator.
The coordinator is the online cluster node with the lowest ``nodeid``.
On standalone (non-clustered) nodes this always returns ``True``.
"""
try:
status = self._api.cluster.status.get() or []
node_entries = [e for e in status if e.get("type") == "node"]
if not node_entries:
return True # Standalone node — no cluster
online_nodes = [n for n in node_entries if n.get("online", 0)]
if not online_nodes:
return True # All nodes offline → assume we're the master
min_id = min(int(n.get("nodeid", 9999)) for n in online_nodes)
for n in online_nodes:
if (
n.get("name") == self._node_name
and int(n.get("nodeid", 9999)) == min_id
):
return True
return False
except Exception:
return True # Can't determine → assume standalone, proceed
def _get_sdn_zone_for_bridge(self, bridge_name: str) -> str | None:
"""Return the SDN zone ID whose ``bridge`` field matches *bridge_name*.
In Proxmox SDN each zone is linked to exactly one OVS bridge via the
``bridge`` property. We look for that mapping so the VNet is always
created in the correct zone instead of guessing by type order.
Falls back to the first zone if no bridge match is found.
"""
try:
zones = self._get_sdn_zones()
# Exact match on bridge field
for zone in zones:
if zone.get("bridge") == bridge_name:
return zone.get("zone")
# Fallback: first available zone
if zones:
return zones[0].get("zone")
except Exception:
pass
return None
def set_vlan(self, vlan_id: int, config) -> None:
"""Create (or update) a VLAN via an SDN VNet on this Proxmox node.
Pre-flight checks (all must pass to proceed):
1. Finds the physical uplink port connected to a switch via LLDP.
Physical ports are those with type ``eth``, ``OVSPort``, or ``""``
in the Proxmox network config (excludes runtime virtuals like
``fwpr*``, ``tap*``, etc.).
2. Verifies that uplink is part of an OVS bridge or OVS bond.
3. Confirms this node is the Corosync quorum master (lowest node-id).
Non-master nodes return silently — the master handles VNet creation.
The SDN VNet is named ``vlan{vid:04d}`` (e.g. ``vlan0007`` for VID 7).
If the VNet already exists its alias is updated. After creating /
updating the VNet the SDN configuration is reloaded via
``PUT /cluster/sdn``.
Args:
vlan_id: VLAN identifier (1-4094).
config: Dict that may contain ``"name"`` for the VLAN alias.
"""
name: str = (
(config.get("name") or f"VLAN{vlan_id}") if config else f"VLAN{vlan_id}"
)
vnet_id = f"vlan{vlan_id:04d}"
# 1. Find switch uplink via LLDP
uplink = self._find_switch_uplink()
if uplink is None:
raise ConnectionException(
f"set_vlan({vlan_id}): no LLDP-detected switch uplink found"
f" on node {self._node_name!r}"
)
# 2. Verify uplink is part of an OVS bridge
ovs_bridge = self._get_ovs_bridge_for_port(uplink)
if ovs_bridge is None:
raise ConnectionException(
f"set_vlan({vlan_id}): uplink {uplink!r} is not part of an OVS bridge"
)
# 3. Only the Corosync master manages SDN VNets
if not self._is_cluster_master():
return
# 4. Find the SDN zone linked to this OVS bridge
zone = self._get_sdn_zone_for_bridge(ovs_bridge)
if not zone:
raise ConnectionException(
f"set_vlan({vlan_id}): no SDN zone found for bridge {ovs_bridge!r}"
)
# 5. Create or update the VNet
try:
self._api.cluster.sdn.vnets.post(
vnet=vnet_id,
zone=zone,
tag=vlan_id,
alias=name,
)
except ResourceException as exc:
err_str = str(exc).lower()
if "already exists" in err_str or "duplicate" in err_str or "500" in err_str:
# VNet already exists — update alias
try:
self._api.cluster.sdn.vnets(vnet_id).put(alias=name)
except Exception:
pass # Best effort
else:
raise ConnectionException(
f"set_vlan({vlan_id}): failed to create VNet {vnet_id!r}: {exc}"
) from exc
# 6. Reload SDN config so the VNet becomes active
try:
self._api.cluster.sdn.put()
except Exception:
pass # Best effort — may not be needed on older PVE versions
def delete_vlan(self, vlan_id: int) -> None:
"""Delete the SDN VNet corresponding to *vlan_id*.
The VNet is identified by the canonical name ``vlan{vid:04d}``.
Only the Corosync quorum master performs the deletion — non-master
nodes return silently.
After deletion the SDN configuration is reloaded via
``PUT /cluster/sdn``.
Args:
vlan_id: VLAN identifier to delete.
"""
if not self._is_cluster_master():
return # Non-master node — master handles VNet deletion
vnet_id = f"vlan{vlan_id:04d}"
try:
self._api.cluster.sdn.vnets(vnet_id).delete()
except ResourceException as exc:
err_str = str(exc).lower()
if "does not exist" in err_str or "404" in str(exc):
return # Already gone — treat as success
raise ConnectionException(
f"delete_vlan({vlan_id}): failed to delete VNet {vnet_id!r}: {exc}"
) from exc
# Reload SDN config
try:
self._api.cluster.sdn.put()
except Exception:
pass # Best effort
# ------------------------------------------------------------------ #
# SNMP / Health
# ------------------------------------------------------------------ #
def get_snmp_config(self):
"""Return SNMP agent config if snmpd is installed and running on the node.
Uses _exec_ssh_command (Proxmox API exec or SSH) to inspect the node.
Returns a SNMPConfigDict or None.
"""
try:
from napalm_device_types.models import SNMPConfigDict
except ImportError:
return None
running = (
self._exec_ssh_command("systemctl is-active snmpd 2>/dev/null || true").strip()
== "active"
)
if not running:
return None
community = "public"
port = 161
try:
conf = self._exec_ssh_command(
"grep -E '^[[:space:]]*(ro|rw)?community' /etc/snmp/snmpd.conf 2>/dev/null | head -5"
)
for line in conf.splitlines():
parts = line.split()
if not parts:
continue
kw = parts[0].lower()
if kw in ("rocommunity", "rwcommunity") and len(parts) >= 2:
community = parts[1]
break
elif kw == "com2sec" and len(parts) >= 4:
community = parts[3]
break
except Exception:
pass
return SNMPConfigDict(running=True, community=community, port=port, version="2c")
def run_device_action(self, action: str) -> dict:
"""Execute a named administrative action on the Proxmox node."""
if action == "fix_snmp":
return self._action_fix_snmp()
if action == "install_lldpd":
return self._action_install_lldpd()
raise NotImplementedError(f"Unknown action: {action!r}")
def _action_install_lldpd(self) -> dict:
"""Install, enable and start lldpd on the Proxmox node.
Proxmox runs Debian/Linux underneath, so this is a plain apt install
followed by enabling the systemd service. lldpd's defaults (listen on
all interfaces) are sufficient to discover the directly-connected
switch via the management bridge (e.g. vmbr0).
"""
lines: list[str] = []
install_out = self._exec_ssh_command(
"DEBIAN_FRONTEND=noninteractive apt-get install -y lldpd 2>&1 | tail -5"
)
lines.append(f"[install] {install_out.strip()[-200:]}")
enable_out = self._exec_ssh_command(
"systemctl enable --now lldpd 2>&1 || service lldpd start 2>&1 || true"
)
lines.append(f"[service] {enable_out.strip()[-200:]}")
verify = self._exec_ssh_command("command -v lldpd 2>/dev/null").strip()
success = bool(verify)
if success:
lines.append("[ok] lldpd installed. Neighbors will appear after a short warm-up period.")
else:
lines.append("[warn] lldpd does not appear to be installed after the attempt.")
return {"success": success, "output": "\n".join(lines)}
def _action_fix_snmp(self) -> dict:
"""Install, configure and start snmpd on the Proxmox node.
Proxmox runs Debian/Linux underneath. _exec_ssh_command runs as root
(either via Proxmox API execute endpoint or SSH with root credentials),
so no sudo is needed.
"""
import base64 as _b64
lines: [].__class__ = []
# 1. Install snmpd and snmp client tools
install_out = self._exec_ssh_command(
"DEBIAN_FRONTEND=noninteractive apt-get install -y snmpd snmp 2>&1 | tail -5"
)
lines.append(f"[install] {install_out.strip()[-200:]}")
# 2. Detect the IP this connection comes from (for firewall rule)
netork_ip = ""
try:
raw = self._exec_ssh_command(
"ss -tnp 2>/dev/null | awk '/sshd/{print $5}' | head -1 | cut -d: -f1"
).strip()
if raw and raw not in ("", "0.0.0.0", "::", "127.0.0.1"):
netork_ip = raw
except Exception:
pass
# 3. Write snmpd.conf via /tmp (no permission issues)
conf_str = (
"agentAddress udp:161\n"
"rocommunity public\n"
"sysLocation Managed by netOrk\n"
"sysContact netork@localhost\n"
)
conf_b64 = _b64.b64encode(conf_str.encode()).decode()
self._exec_ssh_command(f"echo {conf_b64} | base64 -d > /tmp/netork_snmpd.conf")
self._exec_ssh_command(
"mv /tmp/netork_snmpd.conf /etc/snmp/snmpd.conf && "
"chown root:root /etc/snmp/snmpd.conf && chmod 644 /etc/snmp/snmpd.conf"
)
verify = self._exec_ssh_command("cat /etc/snmp/snmpd.conf 2>/dev/null").strip()
if "agentAddress" in verify and "rocommunity" in verify:
lines.append("[config] Wrote /etc/snmp/snmpd.conf — agentAddress udp:161, rocommunity public.")
else:
lines.append(f"[warn] snmpd.conf write may have failed: {verify[:100]}")
# 4. Open firewall if ufw is present
if netork_ip:
try:
ufw = self._exec_ssh_command("command -v ufw 2>/dev/null").strip()
if ufw:
parts = netork_ip.rsplit(".", 1)
subnet = f"{parts[0]}.0/24" if len(parts) == 2 else netork_ip
fw_out = self._exec_ssh_command(
f"ufw allow from {subnet} to any port 161 proto udp 2>&1"
)
lines.append(f"[firewall/ufw] {fw_out.strip()[:200]}")
except Exception as exc:
lines.append(f"[firewall] skipped — {exc}")
# 5. Stop and restart snmpd cleanly (no DBus needed for stop+start)
self._exec_ssh_command(
"service snmpd stop 2>/dev/null; pkill -9 snmpd 2>/dev/null; true"
)
import time as _time
_time.sleep(1)
start_out = self._exec_ssh_command(
"service snmpd start 2>&1 || systemctl start snmpd 2>&1 || true"
)
lines.append(f"[service] {start_out.strip()[-200:]}")
# 6. Verify via local probe
_time.sleep(2)
probe_out = self._exec_ssh_command(
"snmpget -v2c -cpublic -t2 -r0 -Ov 127.0.0.1 1.3.6.1.2.1.1.1.0 2>&1 || true"
).strip()
_snmp_types = ("STRING:", "INTEGER:", "OID:", "Timeticks:", "Hex-STRING:", "IpAddress:")
success = any(t in probe_out for t in _snmp_types)
if success:
lines.append("[ok] SNMP probe successful — community 'public' is working.")
else:
lines.append(f"[warn] SNMP probe failed — output: {probe_out[:200]}")
return {"success": success, "output": "\n".join(lines)}
def get_disk_smart(self) -> dict:
"""Return SMART health data for all disks on this Proxmox node.
Combines the disk list (model, size, wearout) with per-disk SMART
data (health, temperature, percentage used).
Returns a dict keyed by device path, e.g. {"/dev/nvme0n1": {...}}.
"""
import re as _re
result: dict = {}
try:
disks = self._node_api().disks.list.get() or []
except Exception:
return result
for disk in disks:
dev = disk.get("devpath") or disk.get("dev")
if not dev:
continue
entry: dict = {
"model": disk.get("model", ""),
"serial": disk.get("serial", ""),
"type": disk.get("type", ""),
"size": disk.get("size", 0),
"health": disk.get("health", "unknown").lower(),
"wearout": disk.get("wearout"), # NVMe wear indicator 0-100
"temperature": None,
"percentage_used": None,
"available_spare": None,
"reallocated_sectors": None,
"power_on_hours": None,
}
try:
smart = self._node_api().disks.smart.get(disk=dev) or {}
# health from SMART endpoint may be more accurate
if smart.get("health"):
entry["health"] = smart["health"].lower()
text = smart.get("text", "")
# Parse temperature
m = _re.search(r"Temperature[^:]*:\s*(\d+)\s*Celsius", text)
if m:
entry["temperature"] = int(m.group(1))
# NVMe-specific
m = _re.search(r"Percentage Used:\s*(\d+)%", text)
if m:
entry["percentage_used"] = int(m.group(1))
m = _re.search(r"Available Spare:\s*(\d+)%", text)
if m:
entry["available_spare"] = int(m.group(1))
m = _re.search(r"Power On Hours:\s*([\d,]+)", text)
if m:
entry["power_on_hours"] = int(m.group(1).replace(",", ""))
# HDD-specific SMART attributes
for attr in smart.get("attributes", []):
name = attr.get("name", "").lower()
raw = attr.get("raw", "")
try:
raw_int = int(str(raw).split()[0])
except (ValueError, TypeError):
raw_int = None
if "temperature" in name and raw_int is not None:
entry["temperature"] = raw_int
elif "reallocated" in name and "sector" in name and raw_int is not None:
entry["reallocated_sectors"] = raw_int
elif "power_on" in name and raw_int is not None:
entry["power_on_hours"] = raw_int
except Exception:
pass
result[dev] = entry
return result