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.optional_args: _JsonDict = optional_args or {}
|
||||||
|
|
||||||
self._port: int = self.optional_args.get("port", 8006)
|
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._realm: str = self.optional_args.get("realm", "pam")
|
||||||
self._token_name: str | None = self.optional_args.get("token_name")
|
self._token_name: str | None = self.optional_args.get("token_name")
|
||||||
self._token_value: str | None = self.optional_args.get("token_value")
|
self._token_value: str | None = self.optional_args.get("token_value")
|
||||||
@@ -93,6 +95,7 @@ class ProxmoxDriver(HypervisorDriver):
|
|||||||
|
|
||||||
self._api: ProxmoxAPI | None = None
|
self._api: ProxmoxAPI | None = None
|
||||||
self._node_name: str = ""
|
self._node_name: str = ""
|
||||||
|
self._ssh_client: "paramiko.SSHClient | None" = None
|
||||||
|
|
||||||
# Candidate config (merge/replace)
|
# Candidate config (merge/replace)
|
||||||
self._candidate_config: str = ""
|
self._candidate_config: str = ""
|
||||||
@@ -136,6 +139,27 @@ class ProxmoxDriver(HypervisorDriver):
|
|||||||
f"Cannot connect to Proxmox at {self.hostname}:{self._port} — {exc}"
|
f"Cannot connect to Proxmox at {self.hostname}:{self._port} — {exc}"
|
||||||
) from 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:
|
def _resolve_node(self) -> str:
|
||||||
"""Return the Proxmox node name for this host."""
|
"""Return the Proxmox node name for this host."""
|
||||||
if self._node:
|
if self._node:
|
||||||
@@ -160,6 +184,12 @@ class ProxmoxDriver(HypervisorDriver):
|
|||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close the session (Proxmox REST is stateless; nothing to tear down)."""
|
"""Close the session (Proxmox REST is stateless; nothing to tear down)."""
|
||||||
self._api = None
|
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:
|
def is_alive(self) -> _JsonDict:
|
||||||
"""Return connection state."""
|
"""Return connection state."""
|
||||||
@@ -201,28 +231,53 @@ class ProxmoxDriver(HypervisorDriver):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 2. Fall back to SSH if we have credentials
|
# 2. Fall back to SSH using explicit SSH credentials from optional_args,
|
||||||
if not self.username or not self.password:
|
# 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 ""
|
return ""
|
||||||
try:
|
try:
|
||||||
import paramiko # noqa: PLC0415
|
import paramiko # noqa: PLC0415
|
||||||
ssh_port: int = self.optional_args.get("ssh_port", 22)
|
import tempfile, os as _os # noqa: PLC0415
|
||||||
client = paramiko.SSHClient()
|
client = self._ssh_client
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
if client is None or not client.get_transport() or not client.get_transport().is_active():
|
||||||
client.connect(
|
ssh_port: int = self.optional_args.get("ssh_port", 22)
|
||||||
hostname=self.hostname,
|
client = paramiko.SSHClient()
|
||||||
port=ssh_port,
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
username=self.username,
|
connect_kwargs: dict = dict(
|
||||||
password=self.password,
|
hostname=self.hostname,
|
||||||
timeout=self.timeout,
|
port=ssh_port,
|
||||||
look_for_keys=False,
|
username=ssh_user,
|
||||||
allow_agent=False,
|
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)
|
_, stdout, _ = client.exec_command(command, timeout=self.timeout)
|
||||||
output = stdout.read().decode("utf-8", errors="replace")
|
output = stdout.read().decode("utf-8", errors="replace")
|
||||||
client.close()
|
|
||||||
return output
|
return output
|
||||||
except Exception:
|
except Exception:
|
||||||
|
self._ssh_client = None
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _get_version_info(self) -> _JsonDict:
|
def _get_version_info(self) -> _JsonDict:
|
||||||
@@ -681,7 +736,13 @@ class ProxmoxDriver(HypervisorDriver):
|
|||||||
if current_iface not in entry["tagged"]:
|
if current_iface not in entry["tagged"]:
|
||||||
entry["tagged"].append(current_iface)
|
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)
|
# get_network_instances (SDN Zones as VRF-like instances)
|
||||||
@@ -2107,3 +2168,212 @@ class ProxmoxDriver(HypervisorDriver):
|
|||||||
self._api.cluster.sdn.put()
|
self._api.cluster.sdn.put()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Best effort
|
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