Files
OPNexus/backend/app/services/host_status_service.py
T
Julian Boldt 1aba618ad5
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.23.4
- **Fix (Produktiv-Vorfall 2026-10-20):** Das Ändern der Beschreibung eines CARP-VIP wendete das VIP neu an und ließ es auf beiden Firewalls
  im Zustand INIT hängen – OPNexus zeigte trotzdem „alles in Ordnung“. Nur die Beschreibung zu ändern, wendet jetzt nichts mehr an. Nach jeder
  anderen CARP-VIP-Änderung prüft OPNexus den Live-Zustand (MASTER/BACKUP), wendet bei Bedarf einmal erneut an und meldet sonst „Angewendet,
  Prüfung weicht ab“.
- **CARP-Überwachung je VHID:** Neue Warnungen `vip_down` (ein VIP ist auf einem Node nicht aktiv) und `role_mismatch` (ein Node ist nur für
  einen Teil der VIPs MASTER). Bisher zählte der Host schon als MASTER, sobald ein einziges VIP MASTER war.
2026-10-20 13:56:25 +02:00

65 lines
2.5 KiB
Python

"""Live host status (online/offline + CARP master/backup) via getVipStatus.
Each host is queried independently and failures are caught per-host, so one
unreachable firewall never blocks the others from reporting — this backs
the hosts list endpoint's mixed online/offline behaviour.
"""
import asyncio
from app.models.host import OpnsenseHost
from app.services.opnsense_client import OpnsenseAPIError, get_vip_status
def _truthy(value, default: bool) -> bool:
"""OPNsense's getVipStatus returns carp.maintenancemode as a native JSON
bool but other carp.* flags as "1"/"0" strings — normalize both."""
if isinstance(value, bool):
return value
if value is None:
return default
return str(value) == "1"
def _extract_carp_status(data: dict | None) -> str | None:
rows = data.get("rows") if isinstance(data, dict) else None
if not rows:
return None
statuses = {row.get("status", "").upper() for row in rows if isinstance(row, dict)}
if "MASTER" in statuses:
return "MASTER"
if "BACKUP" in statuses:
return "BACKUP"
return None
def _vip_states(data: dict | None) -> dict[str, str]:
"""Per-VHID CARP state ("MASTER", "BACKUP", "DISABLED", "INIT", ...). The host-level carp_status above
collapses these, so one VHID that is down on a node is invisible there."""
rows = data.get("rows") if isinstance(data, dict) else None
return {str(row.get("vhid")): str(row.get("status", "")).upper()
for row in rows or [] if isinstance(row, dict) and row.get("vhid") not in (None, "")}
async def _status_for(host: OpnsenseHost) -> dict:
if not host.enabled:
return {"status": "disabled", "carp_status": None, "status_detail": None}
try:
data = await get_vip_status(host)
except OpnsenseAPIError as exc:
return {"status": "offline", "carp_status": None, "status_detail": str(exc)}
carp = data.get("carp", {}) if isinstance(data, dict) else {}
return {
"status": "online",
"carp_status": _extract_carp_status(data),
"carp_vips": _vip_states(data),
"carp_maintenance_mode": _truthy(carp.get("maintenancemode"), default=False),
"carp_allowed": _truthy(carp.get("allow"), default=True),
"carp_demotion": carp.get("demotion"),
"status_detail": carp.get("status_msg") or None,
}
async def get_statuses(hosts: list[OpnsenseHost]) -> dict:
results = await asyncio.gather(*(_status_for(h) for h in hosts))
return {host.id: result for host, result in zip(hosts, results)}