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
- **Alarme für Gateways, Ressourcen und Dienste:** Neue Ereignisse in Einstellungen › Benachrichtigungen. **Gateway gestört** (standardmäßig **an**) meldet ein Gateway, das OPNsense als „down“ führt oder dessen Paketverlust (ab 20 %) bzw. Latenz (ab 500 ms) über der Schwelle liegt; bewusst abgeschaltete und nicht überwachte Gateways bleiben still. **Ressourcen-Schwelle** (standardmäßig aus) prüft Festplatte (ab 90 %), Arbeitsspeicher (ab 95 %), Swap (ab 50 %) und CPU-Last (ab 2 pro Thread), **Dienst ausgefallen** (standardmäßig aus) eine einstellbare Liste (Unbound, Dnsmasq, DHCP, NTP, OpenVPN, WireGuard, IPsec …) und meldet nur Dienste, die zuvor liefen. Alle drei stützen sich auf die Minutenmessung und lösen erst aus, wenn die letzten 3 (Ressourcen: 5) Messungen übereinstimmen, und enden erst, wenn alle wieder in Ordnung sind: Einzelspitzen und flackernde Verbindungen schicken nichts. Der Gateway-Status (Verlust, Latenz, Abweichung) steht außerdem auf der Host-Seite, als Kachel auf dem Dashboard und als Prometheus-Metriken (`opnexus_host_gateway_*`, `opnexus_host_swap_used_percent`, `opnexus_host_service_running`). Die Messung speichert dafür zusätzlich Swap, CPU-Threads, Gateways und Dienste in der vorhandenen Messreihe (keine neue Tabelle). - **Weitere Benachrichtigungskanäle:** ntfy, Gotify, Telegram, Microsoft Teams (Workflows-/Incoming-Webhook) und Matrix, beliebig viele je Typ, mit „Testen“-Knopf pro Kanal. Zugangsdaten (Tokens, die Teams-Adresse) liegen wie das SMTP-Passwort in der Geheimnis-Ablage, nie in der Datenbank oder in Antworten, und tauchen nicht in Fehlermeldungen oder Logs auf. Webhook und neue Kanäle prüfen die Zieladresse: nur http(s), keine Zugangsdaten in der Adresse, keine lokalen/Link-Local-/Metadaten-Adressen; Weiterleitungen werden nicht verfolgt. Betrifft die bestehende Webhook-Adresse: `localhost`, Loopback, 169.254.x.x und Zugangsdaten in der Adresse (`https://user:pass@…`) werden jetzt abgelehnt. Eine solche gespeicherte Adresse wird nicht mehr beliefert (nur ein Log-Eintrag), und die Benachrichtigungs-Einstellungen lassen sich erst wieder speichern, wenn sie korrigiert oder geleert ist (die Meldung nennt den Grund). Neue Einstellungen kommen mit Migration 0020. - **Bestand übernehmen für Aliase und NAT:** Auf den Seiten Aliase und NAT (Port Forward, 1:1, Outbound) gibt es jetzt **Von Firewall übernehmen** wie bei den Regeln. Der Dialog zeigt, was von Hand in OPNsense angelegt wurde, einzeln zum Übernehmen oder mit „Alle übernehmbaren“ in einem Schritt; verschachtelte Aliase werden in der richtigen Reihenfolge übernommen. Die Übernahme schreibt nichts auf die Firewall: Die Vorschau zeigt direkt danach „keine Änderung“, der nächste Rollout aktualisiert dasselbe Objekt (kein Duplikat). Nicht übernommen werden OPNsense-eigene Aliase, Typen, die OPNexus nicht kennt (z. B. URL, GeoIP), Aliase mit Verweis auf nicht übernommene Aliase sowie NAT-Regeln, die das Modell verfälschen würden (z. B. „Kein Redirect“, verknüpfte Filterregel, mehrere Schnittstellen, „Kein NAT“); die Oberfläche nennt den Grund je Objekt. Outbound-NAT lässt sich nur übernehmen, wenn der Modus der Firewall Hybrid oder Manuell ist. Einstellungen, die OPNexus nicht verwaltet (Protokollierung, Pool-Optionen, Tags, Kategorien), bleiben unverändert und werden angezeigt. - **Images für ARM:** Alle Release-Images (backend, frontend, proxy, updater) gibt es jetzt für `linux/amd64` und `linux/arm64` (Raspberry Pi 4/5, ARM-Server, Apple-Silicon-Docker).
98 lines
3.9 KiB
Python
98 lines
3.9 KiB
Python
"""Gateway health of a firewall (dpinger numbers, as OPNsense reports them).
|
|
|
|
``get_gateway_health(host)`` is the one reusable entry point: a live read of
|
|
/routes/gateway/status, one :class:`GatewayHealth` per gateway. The notification
|
|
engine, the host page and the metrics read the SAME numbers from the periodic
|
|
resource sample (``compact`` / ``from_compact``), so a scrape or a page view never
|
|
fans out to the firewalls; the 1.25 health gate can call ``get_gateway_health``
|
|
directly. Parsing is lenient on purpose: "~" means "nothing measured", never zero.
|
|
"""
|
|
import re
|
|
|
|
from app.models.host import OpnsenseHost
|
|
from app.schemas.gateway import GatewayHealth, GatewayState
|
|
from app.services import opnsense_client as client
|
|
|
|
_NUMBER = re.compile(r"-?\d+(?:\.\d+)?")
|
|
_DEGRADED = {"delay", "loss", "delay+loss"}
|
|
|
|
|
|
def _number(value) -> float | None:
|
|
if value is None:
|
|
return None
|
|
match = _NUMBER.search(str(value))
|
|
return float(match.group()) if match else None
|
|
|
|
|
|
def _state(status: str, measured: bool) -> GatewayState:
|
|
if status == "down":
|
|
return "offline"
|
|
if status == "force_down":
|
|
return "forced_down"
|
|
if status in _DEGRADED:
|
|
return "degraded"
|
|
if status == "none":
|
|
# "none" is also what a gateway without monitoring reports; only numbers make it "online".
|
|
return "online" if measured else "unknown"
|
|
return "unknown"
|
|
|
|
|
|
def parse_gateway_status(payload) -> list[GatewayHealth]:
|
|
items = payload.get("items") if isinstance(payload, dict) else None
|
|
result: list[GatewayHealth] = []
|
|
for item in items or []:
|
|
if not isinstance(item, dict) or not item.get("name"):
|
|
continue
|
|
status = str(item.get("status") or "").lower()
|
|
loss, delay, stddev = _number(item.get("loss")), _number(item.get("delay")), _number(item.get("stddev"))
|
|
if loss is not None and loss >= 100:
|
|
delay = stddev = None # dpinger prints 0.0 ms when nothing answers: no round trip, not a fast one
|
|
measured = loss is not None or delay is not None
|
|
address = item.get("address")
|
|
monitor = item.get("monitor")
|
|
result.append(GatewayHealth(
|
|
name=str(item["name"]),
|
|
address=None if address in (None, "", "~") else str(address),
|
|
status=status or "unknown",
|
|
state=_state(status, measured),
|
|
loss=loss, delay=delay, stddev=stddev,
|
|
monitor=None if monitor in (None, "", "~") else str(monitor),
|
|
))
|
|
return result
|
|
|
|
|
|
async def get_gateway_health(host: OpnsenseHost) -> list[GatewayHealth]:
|
|
"""Live gateway state of ``host``. Raises OpnsenseAPIError if the firewall cannot be asked."""
|
|
return parse_gateway_status(await client.get_gateway_status(host))
|
|
|
|
|
|
def compact(gateways: list[GatewayHealth]) -> list[dict]:
|
|
"""What the resource sampler stores per sample (small, no addresses)."""
|
|
return [
|
|
{"name": g.name, "status": g.status, "state": g.state, "loss": g.loss, "delay": g.delay, "stddev": g.stddev}
|
|
for g in gateways
|
|
]
|
|
|
|
|
|
def from_compact(rows) -> list[GatewayHealth]:
|
|
return [
|
|
GatewayHealth(
|
|
name=r["name"], status=r.get("status") or "unknown", state=r.get("state") or "unknown",
|
|
loss=r.get("loss"), delay=r.get("delay"), stddev=r.get("stddev"),
|
|
)
|
|
for r in rows or [] if isinstance(r, dict) and r.get("name")
|
|
]
|
|
|
|
|
|
def is_bad(gateway: GatewayHealth, loss_threshold_pct: float = 0, delay_threshold_ms: float = 0) -> bool:
|
|
"""Alarm-worthy: down, or (when the thresholds are on) measured loss / latency beyond them.
|
|
A gateway forced down by the admin and one that is not measured are not alarms."""
|
|
if gateway.state == "offline":
|
|
return True
|
|
if loss_threshold_pct and gateway.loss is not None and gateway.loss >= loss_threshold_pct:
|
|
return True
|
|
if delay_threshold_ms and gateway.delay is not None and gateway.delay >= delay_threshold_ms:
|
|
return True
|
|
return False
|
|
|