Files
OPNexus/backend/app/services/notification_checks.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

366 lines
16 KiB
Python

"""Five detection functions, each returning {dedup_key: context} for
whatever's currently bad. Pure detection — no delivery, no state tracking
here (see notification_dedup.py for the open/resolved diffing that turns
these snapshots into actual notifications)."""
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.alias_deployment import AliasDeployment
from app.models.backup_settings import BackupSettings
from app.models.compliance_result import ComplianceResult
from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation
from app.models.one_to_one_deployment import OneToOneDeployment
from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.port_forward_deployment import PortForwardDeployment
from app.models.rule_deployment import RuleDeployment
from app.models.scheduled_maintenance import ScheduledMaintenance
from app.services import backup_store, nat_drift_service, network_drift_service, rule_drift_service
from app.services.notification_dedup import Problems
from app.services.host_status_service import get_statuses
from app.services.live_certificates_service import get_live_certificates
from app.services.opnsense_client import OpnsenseAPIError
# (deployment_type, model, id_attr) — same shape audit_service.py's mapping
# uses, so a revert_failed row can point back at the underlying entity.
_DEPLOYMENT_MODELS = [
("rule", RuleDeployment, "rule_id"),
("alias", AliasDeployment, "alias_id"),
("port_forward", PortForwardDeployment, "port_forward_id"),
("outbound_nat", OutboundNatDeployment, "outbound_nat_id"),
("one_to_one", OneToOneDeployment, "one_to_one_id"),
]
async def _enabled_hosts(db: AsyncSession) -> list[OpnsenseHost]:
result = await db.execute(select(OpnsenseHost).where(OpnsenseHost.enabled.is_(True)))
return list(result.scalars().all())
async def check_host_offline(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
statuses = await get_statuses(hosts)
problems: dict[str, dict] = {}
for host in hosts:
info = statuses.get(host.id, {})
if info.get("status") != "offline":
continue
problems[str(host.id)] = {
"host_id": str(host.id),
"host_name": host.name,
"detail": info.get("status_detail"),
}
return problems
async def check_certificates(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
problems = Problems()
for host in hosts:
try:
certs = await get_live_certificates(host)
except OpnsenseAPIError:
# Unreachable — already reported by check_host_offline, no need
# to double-report it here as a certificate problem. Its open
# certificate problems stay as they are (not evaluable now).
problems.preserve_prefixes.add(f"{host.id}:")
continue
for cert in certs:
if cert.status not in ("expiring_soon", "expired"):
continue
key = f"{host.id}:{cert.uuid}"
problems[key] = {
"host_id": str(host.id),
"host_name": host.name,
"cert_descr": cert.descr,
"common_name": cert.common_name,
"status": cert.status,
"days_until_expiry": cert.days_until_expiry,
}
return problems
async def check_rule_drift(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
names_by_id = {h.id: h.name for h in cluster_hosts}
rows, reachable = await rule_drift_service.compute_cluster_drift_with_reachable(cluster_hosts)
if len(reachable) < 2:
# A member is down (reboot/update): drift cannot be judged, so keep
# what is open instead of resolving it and re-opening it later.
problems.preserve_prefixes.add(f"{cluster_name}:")
continue
for row in rows:
key = f"{cluster_name}:{row['fingerprint']}"
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
missing_names = sorted(
names_by_id[hid] for hid in row["reachable_host_ids"] - row["present_on"]
)
problems[key] = {
"cluster_name": cluster_name,
"description": row["sample"].description,
"present_on": present_names,
"missing_from": missing_names,
}
return problems
_NAT_TYPE_LABELS = {"port_forward": "Port-Forward", "one_to_one": "1:1-NAT", "outbound_nat": "Outbound-NAT"}
async def check_nat_drift(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
names_by_id = {h.id: h.name for h in cluster_hosts}
for nat_type in _NAT_TYPE_LABELS:
rows, reachable = await nat_drift_service.compute_cluster_drift_with_reachable(cluster_hosts, nat_type)
if len(reachable) < 2:
problems.preserve_prefixes.add(f"{cluster_name}:{nat_type}:")
continue
for row in rows:
key = f"{cluster_name}:{nat_type}:{row['fingerprint']}"
present_names = sorted(names_by_id[hid] for hid in row["present_on"])
missing_names = sorted(
names_by_id[hid] for hid in row["reachable_host_ids"] - row["present_on"]
)
problems[key] = {
"cluster_name": cluster_name,
"nat_type": _NAT_TYPE_LABELS[nat_type],
"description": row["sample"].get("descr") or row["sample"].get("description"),
"present_on": present_names,
"missing_from": missing_names,
}
return problems
_NETWORK_LABELS = {
"interfaces": "Schnittstelle", "vlans": "VLAN", "vips": "Virtuelle IP", "gateways": "Gateway", "routes": "Route",
"dns_hosts": "DNS-Eintrag", "dhcp_hosts": "DHCP-Host", "kea_subnets": "DHCP-Subnetz", "kea_reservations": "DHCP-Reservierung",
}
async def check_network_drift(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems = Problems()
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
differences, compared, _unreachable = await network_drift_service.compute_cluster(cluster_hosts)
if len(compared) < 2:
# Not evaluable right now (a node is down, which check_host_offline reports): keep what is open.
problems.preserve_prefixes.add(f"{cluster_name}:")
continue
for row in differences:
key = f"{cluster_name}:{row['category']}:{row['key']}"
problems[key] = {
"cluster_name": cluster_name,
"object": f"{_NETWORK_LABELS.get(row['category'], row['category'])} {row['key']}",
"status": "fehlt auf einem Knoten" if row["status"] == "missing" else "Einstellungen weichen ab",
"present_on": row["present_on"],
"missing_from": row["missing_from"],
}
return problems
async def check_revert_failed(db: AsyncSession) -> dict[str, dict]:
problems: dict[str, dict] = {}
for deployment_type, model, id_attr in _DEPLOYMENT_MODELS:
result = await db.execute(select(model).where(model.status == "revert_failed"))
for deployment in result.scalars().all():
host = await db.get(OpnsenseHost, deployment.host_id)
key = f"{deployment_type}:{deployment.id}"
problems[key] = {
"deployment_type": deployment_type,
"deployment_id": str(deployment.id),
"entity_id": str(getattr(deployment, id_attr)),
"host_id": str(deployment.host_id),
"host_name": host.name if host else None,
"message": deployment.message,
}
return problems
async def check_carp_health(db: AsyncSession) -> dict[str, dict]:
hosts = await _enabled_hosts(db)
by_cluster: dict[str, list[OpnsenseHost]] = defaultdict(list)
for host in hosts:
if host.cluster_name:
by_cluster[host.cluster_name].append(host)
problems: dict[str, dict] = {}
for cluster_name, cluster_hosts in by_cluster.items():
if len(cluster_hosts) < 2:
continue
statuses = await get_statuses(cluster_hosts)
online = [h for h in cluster_hosts if statuses.get(h.id, {}).get("status") == "online"]
if len(online) < 2:
# Can't tell split-brain from a simple outage with only one
# reachable node — check_host_offline already reports that case.
continue
masters = [h for h in online if statuses.get(h.id, {}).get("carp_status") == "MASTER"]
if len(masters) == 2:
key = f"{cluster_name}:split_brain"
problems[key] = {
"cluster_name": cluster_name,
"condition": "split_brain",
"host_names": sorted(h.name for h in online),
}
elif len(masters) == 0:
key = f"{cluster_name}:no_master"
problems[key] = {
"cluster_name": cluster_name,
"condition": "no_master",
"host_names": sorted(h.name for h in online),
}
problems.update(_carp_vip_problems(cluster_name, online, statuses, split_brain=len(masters) == 2))
return problems
def _carp_vip_problems(cluster_name: str, online: list[OpnsenseHost], statuses: dict, split_brain: bool) -> dict[str, dict]:
"""Per-VHID checks. The host-level role is "MASTER" as soon as one VIP is, so a VIP that is down on a
node (a node without the VIP never advertises it, the peer takes it over) looks healthy in the totals."""
problems: dict[str, dict] = {}
for host in online:
vips = statuses.get(host.id, {}).get("carp_vips") or {}
down = sorted(v for v, state in vips.items() if state not in ("MASTER", "BACKUP"))
if down:
problems[f"{cluster_name}:vip_down:{host.name}"] = {
"cluster_name": cluster_name, "condition": "vip_down", "host_names": [host.name], "vhids": down,
}
# One node MASTER on some VHIDs and BACKUP on others: a partial failover. Already covered by the
# split-brain alert when both nodes hold a MASTER.
if not split_brain and {"MASTER", "BACKUP"} <= set(vips.values()):
problems[f"{cluster_name}:role_mismatch:{host.name}"] = {
"cluster_name": cluster_name, "condition": "role_mismatch", "host_names": [host.name],
"vhids": sorted(v for v, state in vips.items() if state == "MASTER"),
}
return problems
async def check_maintenance_unknown(db: AsyncSession) -> dict[str, dict]:
result = await db.execute(select(HostOperation).where(HostOperation.status == "unknown"))
problems: dict[str, dict] = {}
for operation in result.scalars().all():
problems[str(operation.id)] = {
"operation_id": str(operation.id),
"host_id": str(operation.host_id),
"host_name": operation.host_name,
"action": operation.action,
"message": operation.message,
}
return problems
async def check_carp_auto_revert_failed(db: AsyncSession) -> dict[str, dict]:
from app.models.carp_maintenance_window import CarpMaintenanceWindow
result = await db.execute(select(CarpMaintenanceWindow).where(CarpMaintenanceWindow.status == "revert_failed"))
problems: dict[str, dict] = {}
for window in result.scalars().all():
problems[str(window.id)] = {
"window_id": str(window.id),
"host_id": str(window.host_id),
"host_name": window.host_name,
"message": window.message,
}
return problems
# A failed scheduled run never turns "fixed" by itself, so it is reported as an event:
# open for this long after its planned time, then closed without a "resolved" message
# (see notification_service._NO_RESOLVED_NOTICE).
SCHEDULED_FAILURE_WINDOW = timedelta(days=3)
async def check_scheduled_maintenance_failed(db: AsyncSession) -> dict[str, dict]:
since = datetime.now(timezone.utc) - SCHEDULED_FAILURE_WINDOW
result = await db.execute(
select(ScheduledMaintenance).where(
ScheduledMaintenance.status == "failed", ScheduledMaintenance.run_at >= since
)
)
problems: dict[str, dict] = {}
for sched in result.scalars().all():
problems[str(sched.id)] = {
"schedule_id": str(sched.id),
"host_id": str(sched.host_id),
"host_name": sched.host_name,
"action": sched.action,
"run_at": sched.run_at.isoformat(),
"message": (sched.result or {}).get("error"),
}
return problems
async def check_backup_stale(db: AsyncSession) -> dict[str, dict]:
"""Scheduled backups are on but a host has no recent one: three missed intervals
(at least 6 hours) cover a reboot or a short outage without crying wolf."""
cfg = await db.get(BackupSettings, 1)
if cfg is not None and not cfg.scheduled_enabled:
return {}
interval = cfg.interval_hours if cfg is not None else 24
limit = timedelta(hours=max(3 * interval, 6))
now = datetime.now(timezone.utc)
problems: dict[str, dict] = {}
for host in await _enabled_hosts(db):
snapshots = backup_store.list_snapshots(host.id)
newest = snapshots[0]["created_at"] if snapshots else None
if (newest is None and now - host.created_at > limit) or (newest is not None and now - newest > limit):
problems[str(host.id)] = {
"host_id": str(host.id), "host_name": host.name,
"last_backup": newest.isoformat() if newest else "noch nie",
}
return problems
async def check_backup_offsite(db: AsyncSession) -> dict[str, dict]:
cfg = await db.get(BackupSettings, 1)
if cfg is None or not cfg.offsite_enabled or not cfg.last_offsite_error:
return {}
return {"offsite": {"message": cfg.last_offsite_error}}
async def check_compliance(db: AsyncSession) -> dict[str, dict]:
"""Critical and high findings of the last security check, one problem per (host, check); resolved when it passes."""
from app.services import compliance_service as cs
hosts = {h.id: h for h in await _enabled_hosts(db)}
problems: dict[str, dict] = {}
for row in (await db.scalars(select(ComplianceResult))).all():
host = hosts.get(row.host_id)
if host is None or row.error:
continue
for finding in row.findings:
if finding["passed"] is False and finding["severity"] in ("critical", "high"):
params = finding.get("params") or {}
problems[f"{host.id}:{finding['id']}"] = {
"host_id": str(host.id), "host_name": host.name, "check": finding["id"],
"befund": cs.TITLES_DE.get(finding["id"], finding["id"]),
"schweregrad": cs.SEVERITIES_DE[finding["severity"]],
"beispiele": ", ".join(params.get("examples", [])) or None,
}
return problems