From 90d6592159dae00eb598193f907b52d8dc63b7dd Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Thu, 4 Jun 2026 22:40:35 +0200 Subject: [PATCH] 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 --- napalm_proxmox/driver.py | 304 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 287 insertions(+), 17 deletions(-) diff --git a/napalm_proxmox/driver.py b/napalm_proxmox/driver.py index 8c4e8a3..09d9c7c 100644 --- a/napalm_proxmox/driver.py +++ b/napalm_proxmox/driver.py @@ -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