Files
OPNexus/backend/app/routers/dashboard.py
T
Julian Boldt 218a1f14ac
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.18.0
Automatisierung und Sicherheitslage. Ende-zu-Ende gegen einen Test-Stack und drei Test-Firewalls geprüft,
siehe Handbuch „Sicherheitslage“ und „Automatisierung“.

- **API-Tokens** (System › API-Tokens): persönliche Tokens für Skripte, CI, Prometheus und KI-Assistenten,
  Bereiche *Lesen*, *Ausrollen*, *Admin*. Nur als Hash gespeichert, einmal angezeigt, widerrufbar, mit
  Ablaufdatum; Anlegen verlangt das Passwort. Ein Token überschreitet nie die Rechte seines Besitzers.
  Tokens verwalten und das eigene Konto geht nur in einer normalen Anmeldung. Geheimnisse (Roh-Backups,
  Benachrichtigungseinstellungen) liest nur der Bereich *Admin*. Das Audit nennt das Token. Neue Tabelle
  `api_tokens` (Migration `0008`).
- **Prometheus-Metriken** unter `GET /api/metrics` (Host erreichbar, Last, Speicher, Platte, CARP-Rolle,
  offene Aktionen und Probleme, Sicherheitswert), nur aus der Datenbank, ohne Anfragen an die Firewalls.
- **MCP-Endpunkt** `POST /api/mcp` (nur lesend) für KI-Assistenten: Flottenübersicht, Hosts, Auslastung, CARP-Ereignisse,
  Audit, Suche und ein Leser für jeden GET-Endpunkt, mit den Rechten des Tokens.
- **OpenAPI-Schema** jetzt unter `/api/openapi.json` (bisher unter `/openapi.json`, vom Proxy nicht
  erreichbar); die Swagger-/ReDoc-Seiten entfallen (ihre CDN-Skripte verbietet die CSP).
- **Sicherheitslage** (Monitoring › Sicherheitslage): Härtungs- und Regel-Hygiene-Prüfungen je Firewall mit Wert
  0–100: Allow-all am WAN, HTTPS, SSH, SNMP, verdeckte/doppelte/unbeschriftete Regeln, Zeitserver, externe
  Logs, Firmware, Backup-Alter. Liest die Konfiguration nur lesend, speichert sie nicht, alle 6 Stunden und
  auf Knopfdruck. Neue Tabelle `compliance_results` (Migration `0009`).
- Behoben: Änderungen am Benutzer in einer Anfrage (z. B. 2FA aktivieren) gingen verloren, wenn die
  Middleware den Benutzer aus einer anderen Datenbank-Sitzung lieferte; er wird jetzt in der Sitzung der
  Anfrage neu geladen.
2026-10-19 23:54:02 +02:00

231 lines
11 KiB
Python

"""Fleet-wide "what needs my attention" summary for the frontend dashboard
(app/dashboard/page.tsx). Deliberately DB-only — no live OPNsense calls —
so this endpoint stays fast and doesn't fail just because one host is
offline; the dashboard fetches live-only data (certificate expiry, firmware
update availability) itself per host via the existing
/hosts/{id}/certificates and /hosts/{id}/maintenance endpoints, exactly the
way the Certificates and Update Center pages already do, so a slow/offline
host degrades only that one panel instead of the whole page.
"Pending actions" aggregates the same three attention-worthy deployment
statuses ("pending" awaiting confirmation, "error" the deploy call itself
failed, "revert_failed" the rollback call failed) across all deployable
entity kinds (rules, aliases, the three NAT types, policy-hierarchy rules,
IPsec/OpenVPN/WireGuard — the VPN kinds only ever reach "error") plus any host_operations still awaiting manual confirmation
(status in "starting"/"submitted"/"unknown" — mirrors
HostMaintenance.tsx's own definition of "needs a click"). Restore-operation
locks are deliberately NOT included here (no fleet-wide list endpoint
exists for those, only per-host, and it's a rarely-triggered flow) —
a documented v1 gap, not an oversight.
"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.alias import FirewallAlias
from app.models.alias_deployment import AliasDeployment
from app.models.audit import AuditEvent
from app.models.carp_maintenance_window import CarpMaintenanceWindow
from app.models.host import OpnsenseHost
from app.models.host_operation import HostOperation
from app.models.ipsec_tunnel import IpsecTunnel
from app.models.ipsec_tunnel_deployment import IpsecTunnelDeployment
from app.models.one_to_one import OneToOneRule
from app.models.one_to_one_deployment import OneToOneDeployment
from app.models.outbound_nat import OutboundNatRule
from app.models.openvpn_instance import OpenVpnInstance
from app.models.openvpn_instance_deployment import OpenVpnInstanceDeployment
from app.models.outbound_nat_deployment import OutboundNatDeployment
from app.models.policy_hierarchy_rule import PolicyHierarchyRule
from app.models.policy_hierarchy_rule_deployment import PolicyHierarchyRuleDeployment
from app.models.port_forward import PortForwardRule
from app.models.port_forward_deployment import PortForwardDeployment
from app.models.rule import FirewallRule
from app.models.rule_deployment import RuleDeployment
from app.models.wireguard_tunnel import WireguardTunnel
from app.models.wireguard_tunnel_deployment import WireguardTunnelDeployment
from app.schemas.dashboard import ActivityItem, DashboardHost, DashboardSummary, PendingAction
from app.services.host_status_service import get_statuses
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
ATTENTION_STATUSES = ("pending", "error", "revert_failed")
STUCK_OPERATION_STATUSES = ("starting", "submitted", "unknown")
OPERATION_LABELS = {
"reboot": "Neustart",
"update": "Firmware-Update",
"major_update": "Major-Upgrade",
"carp_enter": "CARP-Übergabe",
"carp_leave": "CARP-Normalbetrieb",
}
async def _rule_actions(db: AsyncSession) -> list[PendingAction]:
rows = (
await db.execute(
select(RuleDeployment, FirewallRule.description, OpnsenseHost.name)
.join(FirewallRule, RuleDeployment.rule_id == FirewallRule.id)
.join(OpnsenseHost, RuleDeployment.host_id == OpnsenseHost.id)
.where(RuleDeployment.status.in_(ATTENTION_STATUSES))
)
).all()
return [
PendingAction(
kind="rule", status=dep.status, entity_name=description, host_name=host_name,
message=dep.message, created_at=dep.deployed_at, link=f"/rules?rule={dep.rule_id}",
)
for dep, description, host_name in rows
]
async def _nat_actions(
db: AsyncSession, deployment_model, rule_model, fk_attr: str, kind: str, nat_type: str
) -> list[PendingAction]:
fk_column = getattr(deployment_model, fk_attr)
rows = (
await db.execute(
select(deployment_model, rule_model.description, OpnsenseHost.name)
.join(rule_model, fk_column == rule_model.id)
.join(OpnsenseHost, deployment_model.host_id == OpnsenseHost.id)
.where(deployment_model.status.in_(ATTENTION_STATUSES))
)
).all()
return [
PendingAction(
kind=kind, status=dep.status, entity_name=description, host_name=host_name,
message=dep.message, created_at=dep.deployed_at,
link=f"/nat?type={nat_type}&rule={getattr(dep, fk_attr)}",
)
for dep, description, host_name in rows
]
async def _simple_actions(
db: AsyncSession, deployment_model, parent_model, fk_attr: str, name_column, kind: str, link_template: str
) -> list[PendingAction]:
"""Same shape as _nat_actions for kinds whose entity label is not
`description` and whose page takes a different query parameter;
`link_template` is formatted with the parent id."""
rows = (
await db.execute(
select(deployment_model, name_column, OpnsenseHost.name)
.join(parent_model, getattr(deployment_model, fk_attr) == parent_model.id)
.join(OpnsenseHost, deployment_model.host_id == OpnsenseHost.id)
.where(deployment_model.status.in_(ATTENTION_STATUSES))
)
).all()
return [
PendingAction(
kind=kind, status=dep.status, entity_name=name, host_name=host_name,
message=dep.message, created_at=dep.deployed_at, link=link_template.format(getattr(dep, fk_attr)),
)
for dep, name, host_name in rows
]
async def _policy_hierarchy_actions(db: AsyncSession) -> list[PendingAction]:
rows = (
await db.execute(
select(PolicyHierarchyRuleDeployment, PolicyHierarchyRule.description,
PolicyHierarchyRule.hierarchy_group_id, OpnsenseHost.name)
.join(PolicyHierarchyRule, PolicyHierarchyRuleDeployment.policy_hierarchy_id == PolicyHierarchyRule.id)
.join(OpnsenseHost, PolicyHierarchyRuleDeployment.host_id == OpnsenseHost.id)
.where(PolicyHierarchyRuleDeployment.status.in_(ATTENTION_STATUSES))
)
).all()
return [
PendingAction(
kind="policy_hierarchy", status=dep.status, entity_name=description, host_name=host_name,
message=dep.message, created_at=dep.deployed_at, link=f"/policy-hierarchy?group={group_id}",
)
for dep, description, group_id, host_name in rows
]
async def _stuck_host_operations(db: AsyncSession) -> list[PendingAction]:
rows = (
await db.scalars(
select(HostOperation)
.where(HostOperation.status.in_(STUCK_OPERATION_STATUSES))
.order_by(HostOperation.created_at.desc())
)
).all()
return [
PendingAction(
kind="host_operation", status=op.status,
entity_name=OPERATION_LABELS.get(op.action, op.action), host_name=op.host_name,
message=op.message, created_at=op.created_at, link=f"/hosts/{op.host_id}",
)
for op in rows
]
async def _carp_auto_revert_actions(db: AsyncSession) -> list[PendingAction]:
rows = (
await db.scalars(
select(CarpMaintenanceWindow)
.where(CarpMaintenanceWindow.status.in_(("active", "revert_failed")))
.order_by(CarpMaintenanceWindow.entered_at.desc())
)
).all()
actions = []
for window in rows:
if window.status == "active":
message = f"Automatischer Rückschalt-Zeitpunkt: {window.auto_revert_at.strftime('%d.%m. %H:%M') if window.auto_revert_at else 'unbekannt'}"
else:
message = window.message
actions.append(PendingAction(
kind="carp_auto_revert", status=window.status, entity_name="CARP-Wartungsmodus",
host_name=window.host_name, message=message, created_at=window.entered_at,
link=f"/hosts/{window.host_id}",
))
return actions
async def collect_pending_actions(db: AsyncSession) -> list[PendingAction]:
"""Everything that needs a human, newest first (shared with the metrics endpoint)."""
pending_actions: list[PendingAction] = []
pending_actions += await _rule_actions(db)
pending_actions += await _nat_actions(db, PortForwardDeployment, PortForwardRule, "port_forward_id", "port_forward", "port-forward")
pending_actions += await _nat_actions(db, OneToOneDeployment, OneToOneRule, "one_to_one_id", "one_to_one", "one-to-one")
pending_actions += await _nat_actions(db, OutboundNatDeployment, OutboundNatRule, "outbound_nat_id", "outbound_nat", "outbound-nat")
pending_actions += await _policy_hierarchy_actions(db)
pending_actions += await _simple_actions(db, AliasDeployment, FirewallAlias, "alias_id", FirewallAlias.name, "alias", "/aliases?alias={}")
pending_actions += await _simple_actions(db, IpsecTunnelDeployment, IpsecTunnel, "tunnel_id", IpsecTunnel.description, "ipsec", "/ipsec?tunnel={}")
pending_actions += await _simple_actions(db, OpenVpnInstanceDeployment, OpenVpnInstance, "instance_id", OpenVpnInstance.description, "openvpn", "/openvpn?instance={}")
pending_actions += await _simple_actions(db, WireguardTunnelDeployment, WireguardTunnel, "tunnel_id", WireguardTunnel.server_name, "wireguard", "/wireguard?tunnel={}")
pending_actions += await _stuck_host_operations(db)
pending_actions += await _carp_auto_revert_actions(db)
pending_actions.sort(key=lambda item: item.created_at, reverse=True)
return pending_actions
@router.get("", response_model=DashboardSummary)
async def summary(db: AsyncSession = Depends(get_db)):
hosts = (await db.scalars(select(OpnsenseHost).order_by(OpnsenseHost.name))).all()
statuses = await get_statuses(hosts)
host_summaries = [
DashboardHost(
id=host.id, name=host.name, status=statuses.get(host.id, {}).get("status", "offline"),
carp_status=statuses.get(host.id, {}).get("carp_status"), cluster_name=host.cluster_name,
)
for host in hosts
]
pending_actions = await collect_pending_actions(db)
activity_rows = (
await db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(15))
).all()
recent_activity = [
ActivityItem(
id=row.id, created_at=row.created_at, actor=row.actor, kind=row.kind, action=row.action,
status=row.status, entity_name=row.entity_name, host_name=row.host_name, message=row.message,
)
for row in activity_rows
]
return DashboardSummary(hosts=host_summaries, pending_actions=pending_actions[:30], recent_activity=recent_activity)