fix: accept ssl_verify and verify aliases alongside verify_ssl
Mirrors the robust fallback pattern from napalm-opnsense so the driver works regardless of which key name the caller passes in optional_args. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f3ecf14c8d
commit
90d6592159
+287
-17
@@ -85,7 +85,9 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
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", True)
|
||||
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")
|
||||
@@ -93,6 +95,7 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
|
||||
self._api: ProxmoxAPI | None = None
|
||||
self._node_name: str = ""
|
||||
self._ssh_client: "paramiko.SSHClient | None" = None
|
||||
|
||||
# Candidate config (merge/replace)
|
||||
self._candidate_config: str = ""
|
||||
@@ -136,6 +139,27 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
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:
|
||||
@@ -160,6 +184,12 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
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."""
|
||||
@@ -201,28 +231,53 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Fall back to SSH if we have credentials
|
||||
if not self.username or not self.password:
|
||||
# 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
|
||||
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,
|
||||
)
|
||||
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")
|
||||
client.close()
|
||||
return output
|
||||
except Exception:
|
||||
self._ssh_client = None
|
||||
return ""
|
||||
|
||||
def _get_version_info(self) -> _JsonDict:
|
||||
@@ -681,7 +736,13 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
if current_iface not in entry["tagged"]:
|
||||
entry["tagged"].append(current_iface)
|
||||
|
||||
return result
|
||||
# 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)
|
||||
@@ -2107,3 +2168,212 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
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()
|
||||
raise NotImplementedError(f"Unknown action: {action!r}")
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user