"""Other ProxMenux Monitors reachable from this one: cluster nodes, PBS servers and entries added by hand. Only links are kept here. No credential is stored or sent: the browser opens the other Monitor, which keeps its own sign-in. """ import ipaddress import json import os import re import secrets import socket import ssl import subprocess import threading import time import urllib.parse import urllib.request from concurrent.futures import ThreadPoolExecutor from pathlib import Path import pbs_manager LINKS_FILE = Path(os.environ.get("PROXMENUX_MONITOR_LINKS", "/usr/local/share/proxmenux/monitor_links.json")) SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json")) STORAGE_CFG = Path("/etc/pve/storage.cfg") MONITOR_PORT = 8008 PBS_UI_PORT = 8007 PROBE_TIMEOUT = 3 PROBE_TTL = 300 MAX_MANUAL = 64 PRODUCTS = ("pve", "pbs") _lock = threading.Lock() _probe_cache = {} _snapshot = {"at": 0.0, "data": None} _refresh_lock = threading.Lock() class LinkError(ValueError): def __init__(self, message, *, code="monitor_links_save_failed", params=None): super().__init__(message) self.code = code self.params = params def _load(): try: data = json.loads(LINKS_FILE.read_text()) except (OSError, ValueError): return {"manual": [], "overrides": {}} manual = data.get("manual") if isinstance(data.get("manual"), list) else [] overrides = data.get("overrides") if isinstance(data.get("overrides"), dict) else {} return {"manual": [row for row in manual if isinstance(row, dict)], "overrides": overrides} def _save(data): LINKS_FILE.parent.mkdir(parents=True, exist_ok=True) temporary = LINKS_FILE.with_suffix(".tmp") temporary.write_text(json.dumps(data, indent=2)) os.chmod(temporary, 0o600) os.replace(temporary, LINKS_FILE) def normalize_url(value): """Return the origin of an http(s) address, or raise LinkError.""" if not isinstance(value, str) or not value.strip(): raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required") text = value.strip() if "://" not in text: text = "https://" + text parsed = urllib.parse.urlsplit(text) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address") if parsed.username or parsed.password: raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials") try: port = parsed.port except ValueError as exc: raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc host = parsed.hostname if ":" in host: host = f"[{host}]" return f"{parsed.scheme}://{host}" + (f":{port}" if port else "") def _local_scheme(): try: return "https" if json.loads(SSL_CONFIG_FILE.read_text()).get("enabled") else "http" except (OSError, ValueError, AttributeError): return "http" def _host_url(host, port, scheme=None): if ":" in host and not host.startswith("["): host = f"[{host}]" return f"{scheme or _local_scheme()}://{host}:{port}" def _is_private(host): """Only addresses in private ranges are probed without being asked to.""" try: addresses = {info[4][0] for info in socket.getaddrinfo(host, None)} except OSError: return False try: return bool(addresses) and all(ipaddress.ip_address(a.split("%")[0]).is_private for a in addresses) except ValueError: return False def _own_addresses(): addresses = set() try: import psutil for entries in psutil.net_if_addrs().values(): for entry in entries: if entry.family in (socket.AF_INET, socket.AF_INET6): addresses.add(entry.address.split("%")[0]) except Exception: pass return addresses def _is_this_host(host): """A PBS installed next to PVE is named localhost or one of the host's own addresses.""" try: resolved = {info[4][0].split("%")[0] for info in socket.getaddrinfo(host, None)} except OSError: return False own = _own_addresses() try: return bool(resolved) and all(ipaddress.ip_address(a).is_loopback or a in own for a in resolved) except ValueError: return False def _cluster_nodes(): try: result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"], capture_output=True, text=True, timeout=8) rows = json.loads(result.stdout) if result.returncode == 0 else [] except (OSError, subprocess.SubprocessError, ValueError): return [] nodes = [] for row in rows if isinstance(rows, list) else []: if isinstance(row, dict) and row.get("type") == "node" and not row.get("local") and row.get("name") and row.get("ip"): nodes.append({"key": f"cluster:{row['name']}", "name": str(row["name"]), "product": "pve", "group": "cluster", "host": str(row["ip"])}) return nodes def _storage_pbs(): try: text = STORAGE_CFG.read_text() except OSError: return [] servers, current = [], None for line in text.splitlines(): header = re.match(r"^(\w+):\s*(\S+)", line) if header: current = {"name": header.group(2), "port": PBS_UI_PORT} if header.group(1) == "pbs" else None if current is not None: servers.append(current) continue option = re.match(r"^\s+(server|port|fingerprint)\s+(\S+)", line) if current is not None and option: current[option.group(1)] = option.group(2) return [row for row in servers if row.get("server")] def _pbs_servers(): found = {} for row in _storage_pbs(): host = row["server"] port = row.get("port") if str(row.get("port", "")).isdigit() else PBS_UI_PORT found.setdefault(host, {"name": row["name"], "native_url": _host_url(host, port, "https")}) try: for row in pbs_manager.public_servers(): host = urllib.parse.urlsplit(row.get("url", "")).hostname if host: found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])}) except Exception: pass servers, local = [], None for host, info in found.items(): if _is_this_host(host): # Its web interface is on the address the browser already uses. if local is None: local = {"key": "pbs:local", "name": info["name"], "product": "pbs", "group": "pbs", "local": True} servers.append(local) continue servers.append({"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs", "host": host, "native_url": info["native_url"]}) return servers def _probe(url): """True when a ProxMenux Monitor answers at url; nothing is sent but the request.""" now = time.monotonic() with _lock: cached = _probe_cache.get(url) if cached and cached[0] > now: return cached[1] context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE # liveness only: no credential or token is ever sent online = False try: request = urllib.request.Request(url + "/api/auth/status", headers={"Accept": "application/json"}) with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT, context=context) as response: online = response.status == 200 and "auth_enabled" in json.loads(response.read(4096)) except Exception: online = False with _lock: if len(_probe_cache) > 256: _probe_cache.clear() _probe_cache[url] = (now + PROBE_TTL, online) return online def _resolve(entry, override): """Pick the Monitor address and its state for one discovered or manual entry.""" if override.get("url"): return override["url"], "online" if _probe(override["url"]) else "offline" if entry.get("url"): return entry["url"], "online" if _probe(entry["url"]) else "offline" if entry.get("local"): return None, "local" host = entry["host"] if not _is_private(host): return None, "unknown" for scheme in (_local_scheme(), "https" if _local_scheme() == "http" else "http"): url = _host_url(host, MONITOR_PORT, scheme) if _probe(url): return url, "online" return _host_url(host, MONITOR_PORT), "offline" def list_links(include_hidden=False): platform = pbs_manager.get_platform() data = _load() entries = [] if platform.get("product") != "pbs": entries += _cluster_nodes() + _pbs_servers() for row in data["manual"]: entries.append({"key": f"manual:{row.get('id')}", "name": row.get("name") or row.get("url"), "product": row.get("product") if row.get("product") in PRODUCTS else "pve", "group": "pbs" if row.get("product") == "pbs" else "standalone", "url": row.get("url"), "manual": True}) visible = [e for e in entries if include_hidden or not data["overrides"].get(e["key"], {}).get("hidden")] with ThreadPoolExecutor(max_workers=8) as executor: resolved = list(executor.map(lambda e: _resolve(e, data["overrides"].get(e["key"], {})), visible)) links = [] for entry, (url, state) in zip(visible, resolved): override = data["overrides"].get(entry["key"], {}) links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"], "group": entry["group"], "url": url, "status": state, "native_url": entry.get("native_url"), "manual": bool(entry.get("manual")), "local": bool(entry.get("local")), "hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))}) return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")}, "links": links} def refresh(): """Recompute the list with fresh probes and keep it for the header.""" with _refresh_lock: with _lock: _probe_cache.clear() data = list_links(include_hidden=True) _snapshot.update(at=time.monotonic(), data=data) return data def _refresh_quietly(): try: refresh() except Exception: pass def cached_links(include_hidden=False): """The last list at once; a stale one is renewed in the background.""" data = _snapshot["data"] if data is None: data = refresh() elif time.monotonic() - _snapshot["at"] > PROBE_TTL and not _refresh_lock.locked(): threading.Thread(target=_refresh_quietly, daemon=True, name="monitor-links-refresh").start() if include_hidden: return data return {**data, "links": [row for row in data["links"] if not row["hidden"]]} def start_prewarmer(): def loop(): time.sleep(10) while True: _refresh_quietly() time.sleep(PROBE_TTL) threading.Thread(target=loop, daemon=True, name="monitor-links-prewarmer").start() def add_link(payload): if not isinstance(payload, dict): raise LinkError("Invalid request.", code="monitor_link_invalid_request") name = str(payload.get("name") or "").strip()[:64] url = normalize_url(payload.get("url")) product = payload.get("product") if payload.get("product") in PRODUCTS else "pve" with _lock: data = _load() if len(data["manual"]) >= MAX_MANUAL: raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL}) if any(row.get("url") == url for row in data["manual"]): raise LinkError("This address is already in the list.", code="monitor_link_duplicate") identifier = secrets.token_hex(6) data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname, "url": url, "product": product}) _save(data) return f"manual:{identifier}" def update_link(key, payload): if not isinstance(payload, dict): raise LinkError("Invalid request.", code="monitor_link_invalid_request") with _lock: data = _load() if key.startswith("manual:"): row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None) if row is None: raise LinkError("Node not found.", code="monitor_link_not_found") if "name" in payload: row["name"] = str(payload["name"] or "").strip()[:64] or row["name"] if payload.get("url"): row["url"] = normalize_url(payload["url"]) if payload.get("product") in PRODUCTS: row["product"] = payload["product"] elif re.fullmatch(r"(cluster|pbs):[\w.:\-\[\]]{1,253}", key): override = data["overrides"].setdefault(key, {}) if "url" in payload: if payload["url"]: override["url"] = normalize_url(payload["url"]) else: override.pop("url", None) if "hidden" in payload: override["hidden"] = bool(payload["hidden"]) if not override: data["overrides"].pop(key, None) else: raise LinkError("Node not found.", code="monitor_link_not_found") _save(data) def delete_link(key): with _lock: data = _load() remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key] if len(remaining) == len(data["manual"]): raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove") data["manual"] = remaining _save(data)