dpkg-query now also reports ${source:Package} as source_package, so consumers
can match installed binaries to the correct Debian source (e.g. openssh-server
-> openssh) for accurate OSV vulnerability lookups.
856 lines
35 KiB
Python
856 lines
35 KiB
Python
# Copyright 2025 The NetOrk Project Authors. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""System-level NAPALM getters for Proxmox VE nodes (environment, NTP, SNMP, users, packages, services, updates, disk SMART)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from typing import Any
|
|
|
|
from napalm_proxmox import utils
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_JsonDict = dict[str, Any]
|
|
|
|
|
|
class ProxmoxSystemMixin:
|
|
"""Mixin providing system-level NAPALM methods."""
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 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 as exc:
|
|
logger.debug("Failed to fetch hardware sensors: %s", exc)
|
|
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_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 as exc:
|
|
logger.debug("Failed to fetch Proxmox users: %s", exc)
|
|
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 as exc:
|
|
logger.debug("Failed to fetch roles for user %s: %s", uid, exc)
|
|
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
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 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 as exc:
|
|
logger.debug("Failed to fetch available APT updates: %s", exc)
|
|
|
|
# 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}\\t${source:Package}\\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"
|
|
# Debian source package (differs from the binary for split packages,
|
|
# e.g. openssh-server → openssh). Needed for accurate OSV matching.
|
|
source_package = parts[4] if len(parts) > 4 and parts[4] else name
|
|
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",
|
|
"source_package": source_package,
|
|
"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"})
|
|
except Exception as exc:
|
|
logger.debug("Failed to check for lldpd: %s", exc)
|
|
|
|
# 1. Available package updates
|
|
try:
|
|
updates = self.get_available_updates()
|
|
if updates:
|
|
warnings.append({
|
|
"code": "updates_available",
|
|
"meta": {
|
|
"count": len(updates),
|
|
"packages": [u["name"] for u in updates],
|
|
},
|
|
})
|
|
except Exception as exc:
|
|
logger.debug("Failed to check available updates: %s", exc)
|
|
|
|
# 2. Subscription status
|
|
try:
|
|
sub = self._get_node_subscription()
|
|
status = sub.get("status", "")
|
|
if status in ("NotFound", "Invalid", "Expired"):
|
|
warnings.append({"code": "no_subscription", "meta": {"status": status}})
|
|
except Exception as exc:
|
|
logger.debug("Failed to check subscription status: %s", exc)
|
|
|
|
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("\u25cf").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("\u25cf").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 as exc:
|
|
logger.debug("Failed to fetch available updates: %s", exc)
|
|
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}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# 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 as exc:
|
|
logger.debug("Failed to parse snmpd.conf: %s", exc)
|
|
|
|
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: list[str] = []
|
|
|
|
# 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 as exc:
|
|
logger.debug("Failed to detect network origin IP: %s", exc)
|
|
|
|
# 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)}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# set_hostname
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def set_hostname(self, new_hostname: str) -> None:
|
|
"""Set the system hostname on the Proxmox node.
|
|
|
|
Performs all steps required for a clean rename on a Debian/Proxmox host:
|
|
|
|
1. ``/etc/hostname`` — short hostname only.
|
|
2. ``/etc/hosts`` — old hostname replaced by new (short or FQDN).
|
|
3. ``/etc/mailname`` — FQDN, if the file exists.
|
|
4. Postfix ``myhostname`` — updated via ``postconf -e`` if Postfix is
|
|
installed.
|
|
5. Runtime hostname — applied immediately via ``hostname(1)`` so SSH
|
|
and the Proxmox API see the new name without a reboot.
|
|
6. ``/etc/pve/nodes/<old>`` → ``/etc/pve/nodes/<new>`` rename so
|
|
VM/CT configs are found after the reboot under the new node name.
|
|
7. Proxmox node TLS certificate — regenerated via
|
|
``pvenode cert create --overwrite``.
|
|
|
|
**Important**: the Proxmox *node name* (shown in the web UI and stored
|
|
in ``/etc/pve/nodes/<name>/``) only changes after a full reboot.
|
|
Until then the web UI still shows the old node name, but the OS-level
|
|
hostname and the SSL certificate already reflect the new value.
|
|
|
|
Accepts a bare hostname (``pve-see``) or an FQDN
|
|
(``pve-see.see.local``).
|
|
"""
|
|
import base64 as _b64
|
|
|
|
if "." in new_hostname:
|
|
short_hostname, domain = new_hostname.split(".", 1)
|
|
fqdn = new_hostname
|
|
else:
|
|
short_hostname = new_hostname
|
|
domain = None
|
|
fqdn = new_hostname
|
|
|
|
# Derive old short hostname for substitution in /etc/hosts
|
|
old_short = self._exec_ssh_command(
|
|
"cat /etc/hostname 2>/dev/null || hostname -s 2>/dev/null"
|
|
).strip().split(".")[0]
|
|
|
|
# ── 1. /etc/hostname ─────────────────────────────────────────────
|
|
hostname_b64 = _b64.b64encode(f"{short_hostname}\n".encode()).decode()
|
|
self._exec_ssh_command(f"echo {hostname_b64} | base64 -d > /etc/hostname")
|
|
|
|
# ── 2. /etc/hosts ────────────────────────────────────────────────
|
|
# Read the file, substitute in Python (safe for all characters),
|
|
# write back via base64 to avoid shell-escaping issues.
|
|
if old_short and old_short != short_hostname:
|
|
hosts_raw = self._exec_ssh_command("cat /etc/hosts 2>/dev/null")
|
|
# Replace whole-word occurrences of the old short hostname.
|
|
# Both "hostname" and "hostname.domain" forms are covered because
|
|
# the new value is the full fqdn when a domain was given.
|
|
import re as _re
|
|
new_replace = fqdn if domain else short_hostname
|
|
hosts_new = _re.sub(
|
|
r"\b" + _re.escape(old_short) + r"\b",
|
|
new_replace,
|
|
hosts_raw,
|
|
)
|
|
hosts_b64 = _b64.b64encode(hosts_new.encode()).decode()
|
|
self._exec_ssh_command(
|
|
f"echo {hosts_b64} | base64 -d > /tmp/_netork_hosts"
|
|
f" && mv /tmp/_netork_hosts /etc/hosts"
|
|
f" && chmod 644 /etc/hosts"
|
|
)
|
|
|
|
# ── 3. /etc/mailname ─────────────────────────────────────────────
|
|
mailname_b64 = _b64.b64encode(f"{fqdn}\n".encode()).decode()
|
|
self._exec_ssh_command(
|
|
f"[ -f /etc/mailname ]"
|
|
f" && echo {mailname_b64} | base64 -d > /etc/mailname"
|
|
f" || true"
|
|
)
|
|
|
|
# ── 4. Postfix myhostname ─────────────────────────────────────────
|
|
self._exec_ssh_command(
|
|
f"command -v postconf >/dev/null 2>&1"
|
|
f" && postconf -e 'myhostname = {fqdn}'"
|
|
f" || true"
|
|
)
|
|
|
|
# ── 5. Apply hostname at runtime ─────────────────────────────────
|
|
self._exec_ssh_command(f"hostname '{short_hostname}'")
|
|
|
|
# ── 6. Migrate /etc/pve/nodes/<old> → /etc/pve/nodes/<new> ──────
|
|
# Proxmox stores VM and CT configs under /etc/pve/nodes/<nodename>/.
|
|
# This rename MUST happen before the reboot while pve-cluster is
|
|
# running and pmxcfs is mounted — pmxcfs supports the rename live.
|
|
# Without this step the node boots under the new hostname, finds an
|
|
# empty /etc/pve/nodes/<new>/ and all VMs appear missing.
|
|
if old_short and old_short != short_hostname:
|
|
pve_old = f"/etc/pve/nodes/{old_short}"
|
|
pve_new = f"/etc/pve/nodes/{short_hostname}"
|
|
migrate_out = self._exec_ssh_command(
|
|
f"if [ -d '{pve_old}' ] && [ ! -e '{pve_new}' ]; then"
|
|
f" mv '{pve_old}' '{pve_new}'"
|
|
f" && echo 'migrated {pve_old} -> {pve_new}';"
|
|
f"elif [ -d '{pve_new}' ]; then"
|
|
f" echo 'target already exists, skipping migration';"
|
|
f"else"
|
|
f" echo 'source {pve_old} not found, skipping migration';"
|
|
f"fi"
|
|
)
|
|
logger.info("Proxmox node dir migration: %s", migrate_out)
|
|
|
|
# ── 7. Regenerate Proxmox node TLS certificate ───────────────────
|
|
# Use pvenode cert create --overwrite rather than pvecm updatecerts -f.
|
|
# pvecm updatecerts -f restarts pve-cluster which briefly unmounts the
|
|
# cluster filesystem (/etc/pve) and can crash running VMs.
|
|
# pvenode cert create --overwrite only touches the node's own cert and
|
|
# restarts pveproxy (safe while VMs are running).
|
|
cert_out = self._exec_ssh_command(
|
|
"pvenode cert create --overwrite 1 2>&1"
|
|
" || { systemctl restart pveproxy 2>&1; echo 'restarted pveproxy'; }"
|
|
" || true"
|
|
)
|
|
logger.info("Proxmox cert regeneration: %s", cert_out[:200])
|
|
|
|
logger.info(
|
|
"Proxmox set_hostname: %s → %s (fqdn: %s). "
|
|
"Reboot required for node name to update in web UI / cluster.",
|
|
old_short, short_hostname, fqdn,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# _send_command — generic SSH command helper used by netOrk core
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _send_command(self, command: str) -> str:
|
|
"""Execute *command* on the node and return its output.
|
|
|
|
Delegates to _exec_ssh_command so that netOrk's generic helpers
|
|
(e.g. the reboot action) work without knowing the driver internals.
|
|
"""
|
|
return self._exec_ssh_command(command)
|
|
|
|
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 as exc:
|
|
logger.debug("Failed to list disks: %s", exc)
|
|
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 as exc:
|
|
logger.debug("Failed to fetch SMART data for %s: %s", dev, exc)
|
|
result[dev] = entry
|
|
|
|
return result
|
|
|
|
def get_system_config(self) -> dict[str, Any]:
|
|
"""Return system-level configuration for this Proxmox node.
|
|
|
|
Collected from ``/cluster/status`` and the node config API:
|
|
|
|
* ``cluster_name`` (str | None) — Proxmox cluster name; ``None`` when
|
|
the node is standalone (not part of a cluster).
|
|
* ``cluster_nodes`` (list[str]) — hostnames of all online cluster nodes.
|
|
* ``hostname`` (str) — this node's name as reported by the cluster.
|
|
* ``timezone`` (str | None) — e.g. ``"Europe/Berlin"``.
|
|
* ``ntp_servers`` (list[str]).
|
|
* ``ssh_port`` (int) — always 22 for Proxmox nodes.
|
|
* ``ssh_password_auth`` (bool) — ``False`` as a safe default.
|
|
"""
|
|
cfg: dict[str, Any] = {
|
|
"cluster_name": None,
|
|
"cluster_nodes": [],
|
|
"hostname": self._node_name,
|
|
"timezone": None,
|
|
"ntp_servers": [],
|
|
"ssh_port": 22,
|
|
"ssh_password_auth": False,
|
|
}
|
|
|
|
# Cluster name and node list from /cluster/status
|
|
try:
|
|
status = self._api.cluster.status.get() or []
|
|
for entry in status:
|
|
if entry.get("type") == "cluster":
|
|
cfg["cluster_name"] = entry.get("name")
|
|
elif entry.get("type") == "node" and entry.get("online"):
|
|
cfg["cluster_nodes"].append(entry.get("name", ""))
|
|
except Exception as exc:
|
|
logger.debug("Failed to fetch cluster status for system config: %s", exc)
|
|
|
|
# Timezone from node time API
|
|
try:
|
|
time_cfg = self._node_api().time.get() or {}
|
|
cfg["timezone"] = time_cfg.get("timezone") or None
|
|
except Exception as exc:
|
|
logger.debug("Failed to fetch node time config: %s", exc)
|
|
|
|
return cfg
|