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
- **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.
65 lines
2.5 KiB
Python
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)}
|