Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4c8c4ff3a |
No files matched your search
@@ -31,6 +31,12 @@ den Einstellungen.
|
||||
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
|
||||
ansteht): Merlin …
|
||||
|
||||
## [1.23.0] "Harrier" — 2026-10-20
|
||||
|
||||
- **Sicherheitslage im Alltag**: Dashboard-Karte mit den Firewalls mit dem niedrigsten Wert; Benachrichtigung bei kritischen und hohen
|
||||
Befunden (je Firewall und Prüfung, mit „behoben“-Meldung; standardmäßig aus; Migration `0017`); druckbarer **Sicherheitsbericht**
|
||||
(Knopf „Bericht“, Drucken/PDF über den Browser).
|
||||
|
||||
## [1.22.0] "Peregrine" — 2026-10-20
|
||||
|
||||
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
|
||||
|
||||

|
||||
|
||||
> **Stand:** `1.22.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
> **Stand:** `1.23.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
|
||||
|
||||
## Funktionen
|
||||
|
||||
@@ -62,7 +62,7 @@ Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelie
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.22.0
|
||||
OPNEXUS_VERSION=1.23.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
|
||||
|
||||

|
||||
|
||||
> **Status:** `1.22.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
> **Status:** `1.23.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -64,7 +64,7 @@ For access from other machines and for production use with TLS (bundled Caddy pr
|
||||
|
||||
```bash
|
||||
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
|
||||
OPNEXUS_VERSION=1.22.0
|
||||
OPNEXUS_VERSION=1.23.0
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -52,6 +52,8 @@ class NotificationSettings(Base):
|
||||
carp_auto_revert_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
scheduled_maintenance_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
backup_failed_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
# Off by default: switching it on for an existing fleet reports every long-standing finding at once.
|
||||
compliance_alerts_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=300)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
@@ -13,6 +13,7 @@ ProblemType = Literal[
|
||||
"scheduled_maintenance_failed",
|
||||
"backup_stale",
|
||||
"backup_offsite_failed",
|
||||
"compliance_finding",
|
||||
]
|
||||
|
||||
|
||||
@@ -43,6 +44,7 @@ class NotificationSettingsRead(BaseModel):
|
||||
carp_auto_revert_failed_enabled: bool
|
||||
scheduled_maintenance_failed_enabled: bool = True
|
||||
backup_failed_enabled: bool = True
|
||||
compliance_alerts_enabled: bool = False
|
||||
|
||||
poll_interval_seconds: int
|
||||
updated_at: datetime
|
||||
@@ -81,6 +83,7 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
carp_auto_revert_failed_enabled: bool | None = None
|
||||
scheduled_maintenance_failed_enabled: bool | None = None
|
||||
backup_failed_enabled: bool | None = None
|
||||
compliance_alerts_enabled: bool | None = None
|
||||
|
||||
poll_interval_seconds: int | None = Field(
|
||||
default=None, ge=settings.notification_poll_min_interval_seconds
|
||||
|
||||
@@ -31,6 +31,15 @@ BACKUP_MAX_AGE_DAYS = 7
|
||||
PENALTY = {"critical": 30, "high": 15, "medium": 8, "low": 3, "info": 0}
|
||||
SEVERITY_ORDER = ("critical", "high", "medium", "low", "info")
|
||||
MAX_EXAMPLES = 5
|
||||
# German titles for notifications (the UI translates the check ids itself).
|
||||
TITLES_DE = {
|
||||
"webgui_https": "Web-Oberfläche nur über HTTPS", "ssh_hardened": "SSH abgesichert", "snmp_community": "SNMP-Community nicht „public“",
|
||||
"wan_any_any": "Keine Allow-all-Regel am WAN", "internal_any_any": "Keine Allow-all-Regel mit beliebiger Quelle (intern)",
|
||||
"rules_shadowed": "Keine verdeckten Regeln", "rules_duplicate": "Keine doppelten Regeln", "rules_documented": "Regeln sind beschriftet",
|
||||
"rules_unused": "Regeln in Benutzung", "ntp_configured": "Zeitserver gesetzt", "remote_logging": "Logs liegen auch woanders",
|
||||
"ids_enabled": "Einbruchserkennung aktiv", "firmware_current": "Firmware aktuell", "backup_recent": "Aktuelles Backup",
|
||||
}
|
||||
SEVERITIES_DE = {"critical": "kritisch", "high": "hoch", "medium": "mittel", "low": "niedrig", "info": "Hinweis"}
|
||||
|
||||
|
||||
def grade(score: int) -> str:
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -319,3 +320,24 @@ async def check_backup_offsite(db: AsyncSession) -> dict[str, dict]:
|
||||
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
|
||||
@@ -36,6 +36,7 @@ _LABELS = {
|
||||
"scheduled_maintenance_failed": "Geplante Wartung nicht ausgeführt",
|
||||
"backup_stale": "Kein aktuelles Backup",
|
||||
"backup_offsite_failed": "Offsite-Backup fehlgeschlagen",
|
||||
"compliance_finding": "Sicherheitsbefund",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ _CHECKS = [
|
||||
("scheduled_maintenance_failed", "scheduled_maintenance_failed_enabled", notification_checks.check_scheduled_maintenance_failed),
|
||||
("backup_stale", "backup_failed_enabled", notification_checks.check_backup_stale),
|
||||
("backup_offsite_failed", "backup_failed_enabled", notification_checks.check_backup_offsite),
|
||||
("compliance_finding", "compliance_alerts_enabled", notification_checks.check_compliance),
|
||||
]
|
||||
|
||||
# Event-like problems: when they drop out of the window nothing was "fixed", so no
|
||||
|
||||
@@ -25,7 +25,7 @@ async def update_settings(db: AsyncSession, payload: NotificationSettingsUpdate)
|
||||
"smtp_from_address", "smtp_to_addresses",
|
||||
"host_offline_enabled", "cert_expiring_enabled", "rule_drift_enabled",
|
||||
"revert_failed_enabled", "maintenance_unknown_enabled", "carp_health_enabled", "nat_drift_enabled",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled",
|
||||
"network_drift_enabled", "carp_auto_revert_failed_enabled", "scheduled_maintenance_failed_enabled", "backup_failed_enabled", "compliance_alerts_enabled",
|
||||
"poll_interval_seconds",
|
||||
]
|
||||
for field in simple_fields:
|
||||
|
||||
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
|
||||
Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
"""
|
||||
|
||||
APP_VERSION = "1.22.0"
|
||||
APP_CODENAME = "Peregrine"
|
||||
APP_VERSION = "1.23.0"
|
||||
APP_CODENAME = "Harrier"
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Benachrichtigung bei kritischen/hohen Sicherheitsbefunden (standardmäßig aus).
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
Create Date: 2026-10-20
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0017"
|
||||
down_revision = "0016"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_settings ADD COLUMN IF NOT EXISTS compliance_alerts_enabled boolean NOT NULL DEFAULT false")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE notification_settings DROP COLUMN IF EXISTS compliance_alerts_enabled")
|
||||
@@ -324,3 +324,42 @@ def test_unused_finding_is_unknown_while_collecting_and_a_low_finding_when_found
|
||||
found = ids(cs.evaluate(xml, firmware_status="none", newest_backup_age_s=1, unused_rules={"unused": ["old vpn rule"], "collecting": 0}))
|
||||
assert found["rules_unused"]["passed"] is False and found["rules_unused"]["severity"] == "low"
|
||||
assert found["rules_unused"]["params"] == {"count": 1, "examples": ["old vpn rule"]}
|
||||
|
||||
|
||||
# ── notifications ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_and_high_findings_become_problems_that_resolve_when_fixed(client, respx_mock):
|
||||
from app.database import SessionLocal
|
||||
from app.services import notification_checks
|
||||
_mock_host(respx_mock, _config(rules=_mvc_rule(interface="wan", descr="Allow ALL"),
|
||||
ssh="<ssh><enabled>enabled</enabled><permitrootlogin>1</permitrootlogin><passwordauth>1</passwordauth></ssh>"))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {} # nothing checked yet
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
problems = await notification_checks.check_compliance(db)
|
||||
assert set(problems) == {f"{host['id']}:wan_any_any", f"{host['id']}:ssh_hardened"}
|
||||
wan = problems[f"{host['id']}:wan_any_any"]
|
||||
assert wan["host_name"] == "fw1" and wan["schweregrad"] == "kritisch" and wan["beispiele"] == "Allow ALL" and wan["befund"].startswith("Keine Allow-all")
|
||||
# The firewall is fixed: next check, the problems are gone (-> "resolved").
|
||||
respx_mock.clear()
|
||||
_mock_host(respx_mock, _config())
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alerts_are_off_by_default_and_unreachable_hosts_raise_none(client, respx_mock):
|
||||
from app.database import SessionLocal
|
||||
from app.services import notification_checks
|
||||
assert (await client.get("/api/notifications/settings")).json()["compliance_alerts_enabled"] is False
|
||||
assert (await client.put("/api/notifications/settings", json={"compliance_alerts_enabled": True})).json()["compliance_alerts_enabled"] is True
|
||||
respx_mock.get(url__regex=r".*/getVipStatus").mock(return_value=httpx.Response(200, json={"rows": []}))
|
||||
host = await _create_host(client, "fw1", "https://fw1.test")
|
||||
respx_mock.get(url__regex=r".*/core/backup/download/this").mock(return_value=httpx.Response(500))
|
||||
await client.post(f"/api/hosts/{host['id']}/compliance/check")
|
||||
async with SessionLocal() as db:
|
||||
assert await notification_checks.check_compliance(db) == {}
|
||||
@@ -956,6 +956,13 @@ bleibt eine Überschneidung über Aliase unentdeckt, aber es gibt keine falschen
|
||||
Befunde. Beide Regelformate (OPNsense 26.x und ältere Installationen) werden gelesen.
|
||||
Die Werte stehen auch als Prometheus-Metriken zur Verfügung (siehe unten).
|
||||
|
||||
Das **Dashboard** zeigt die Firewalls mit dem niedrigsten Wert. Der Knopf **Bericht** erzeugt eine druckbare Übersicht
|
||||
aller geprüften Firewalls mit ihren offenen Befunden und Hinweisen zur Behebung; im Browser „Drucken / als PDF speichern“
|
||||
wählen. In den Einstellungen › Benachrichtigungen lässt sich **„Kritischer oder hoher Sicherheitsbefund“**
|
||||
einschalten (standardmäßig aus, damit ein bestehender Bestand nicht plötzlich alle alten Befunde auf einmal meldet): Pro
|
||||
Firewall und Prüfung kommt eine Meldung, sobald sie auftritt, und eine „behoben“-Meldung, wenn sie nach der nächsten
|
||||
Prüfung verschwunden ist.
|
||||
|
||||
### Über OPNexus
|
||||
|
||||
Ein Klick auf das Logo öffnet **About**: alle Hosts mit Status, CARP-Rolle, Cluster,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/ui/Card";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { StatusDot } from "@/components/ui/StatusDot";
|
||||
import { api, DashboardSummary, NotificationProblem } from "@/lib/api";
|
||||
import { api, ComplianceHost, DashboardSummary, NotificationProblem } from "@/lib/api";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
@@ -115,6 +115,7 @@ export default function DashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
|
||||
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
|
||||
const [posture, setPosture] = useState<ComplianceHost[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Pending confirmations expire after a few minutes, so keep the summary
|
||||
@@ -163,6 +164,8 @@ export default function DashboardPage() {
|
||||
})
|
||||
.catch((e) => setError(errorMessage(e, "dashboard.loadFailed")));
|
||||
|
||||
api.listCompliance().then((d) => active && setPosture(d)).catch(() => active && setPosture([]));
|
||||
|
||||
api
|
||||
.notificationProblems(false)
|
||||
.then((data) => {
|
||||
@@ -242,6 +245,26 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title={t("dashboard.securityPosture")} icon={ShieldAlert}>
|
||||
{posture === null ? (
|
||||
<Skeleton className="h-10" />
|
||||
) : posture.filter((h) => h.score !== null).length === 0 ? (
|
||||
<p className="text-sm text-slate-500">{t("dashboard.postureNone")} <Link href="/security" className="text-sky-700 hover:underline">{t("dashboard.postureOpen")}</Link></p>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{[...posture].filter((h) => h.score !== null).sort((a, b) => (a.score ?? 0) - (b.score ?? 0)).slice(0, 6).map((h) => (
|
||||
<li key={h.host_id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<Link href="/security" className="min-w-0 truncate font-medium text-sky-700 hover:underline">{h.host_name}</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
{h.counts && h.counts.critical > 0 && <Badge tone="danger">{h.counts.critical} {t("security.severity.critical")}</Badge>}
|
||||
<Badge tone={h.grade === "good" ? "success" : h.grade === "fair" ? "warning" : "danger"}>{h.score} / 100</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title={t("dashboard.pendingActions")} icon={ClipboardList}>
|
||||
{summary === null ? (
|
||||
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { api, ComplianceFinding, ComplianceHost, RuleHit, Severity } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { useReadOnly } from "@/lib/useReadOnly";
|
||||
import { CheckCircle2, CircleHelp, Info, RefreshCw, ShieldAlert, XCircle } from "lucide-react";
|
||||
import { CheckCircle2, CircleHelp, FileText, Info, RefreshCw, ShieldAlert, XCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SEVERITY_TONE: Record<Severity, "danger" | "warning" | "info" | "neutral"> = {
|
||||
@@ -84,12 +85,18 @@ export default function SecurityPage() {
|
||||
{t("security.title")}
|
||||
<InfoTip text={t("security.intro")} size={18} />
|
||||
</h1>
|
||||
<fieldset disabled={readOnly} className="contents">
|
||||
<Button onClick={() => runCheck(true)} disabled={busy} loading={busy}>
|
||||
<RefreshCw size={16} />
|
||||
{t("security.checkAll")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/security/report" className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">
|
||||
<FileText size={16} />
|
||||
{t("report.open_report")}
|
||||
</Link>
|
||||
<fieldset disabled={readOnly} className="contents">
|
||||
<Button onClick={() => runCheck(true)} disabled={busy} loading={busy}>
|
||||
<RefreshCw size={16} />
|
||||
{t("security.checkAll")}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { api, ComplianceFinding, ComplianceHost, Severity } from "@/lib/api";
|
||||
import { useLanguage } from "@/components/LanguageProvider";
|
||||
import { APP_VERSION } from "@/lib/version";
|
||||
import { Printer } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const RANK: Record<Severity, number> = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
|
||||
/** Printable security report of the whole fleet ("Print / save as PDF" in the browser). */
|
||||
export default function SecurityReportPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [hosts, setHosts] = useState<ComplianceHost[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// A report is printed on white paper whatever theme the screen uses.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
let previous = root.dataset.theme;
|
||||
const before = () => { previous = root.dataset.theme; root.dataset.theme = "light"; };
|
||||
const after = () => { if (previous === undefined) delete root.dataset.theme; else root.dataset.theme = previous; };
|
||||
window.addEventListener("beforeprint", before);
|
||||
window.addEventListener("afterprint", after);
|
||||
return () => { window.removeEventListener("beforeprint", before); window.removeEventListener("afterprint", after); after(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.listCompliance()
|
||||
.then((list) => Promise.all(list.filter((h) => h.checked_at).map((h) => api.getHostCompliance(h.host_id))))
|
||||
.then((details) => setHosts(details.sort((a, b) => (a.score ?? 101) - (b.score ?? 101))))
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
const failed = (h: ComplianceHost): ComplianceFinding[] =>
|
||||
(h.findings ?? []).filter((f) => f.passed === false).sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl bg-white p-6 text-slate-900 print:max-w-none print:p-0">
|
||||
<div className="mb-6 flex items-start justify-between print:mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("report.title")}</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("report.generated", { when: new Date().toLocaleString(locale), version: APP_VERSION })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 print:hidden">
|
||||
<Link href="/security" className="inline-flex items-center rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">{t("report.back")}</Link>
|
||||
<Button onClick={() => window.print()}><Printer size={16} />{t("report.print")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{t("security.loadFailed")}</p>}
|
||||
{hosts === null && !error && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
|
||||
{hosts?.length === 0 && <p className="text-sm text-slate-500">{t("security.neverChecked")}</p>}
|
||||
{hosts && hosts.length > 0 && (
|
||||
<table className="mb-8 w-full text-left text-sm">
|
||||
<thead className="border-b border-slate-300 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">{t("overlay.colName")}</th>
|
||||
<th className="py-2 font-medium">{t("report.score")}</th>
|
||||
<th className="py-2 font-medium">{t("report.open")}</th>
|
||||
<th className="py-2 font-medium">{t("report.checked")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hosts.map((h) => (
|
||||
<tr key={h.host_id} className="border-b border-slate-100">
|
||||
<td className="py-1.5 font-medium">{h.host_name}</td>
|
||||
<td className="py-1.5">{h.score === null ? "—" : `${h.score} / 100`}</td>
|
||||
<td className="py-1.5">{h.counts ? (["critical", "high", "medium", "low"] as Severity[]).map((s) => `${h.counts![s]} ${t(`security.severity.${s}`)}`).join(" · ") : "—"}</td>
|
||||
<td className="py-1.5 text-slate-500">{h.checked_at ? new Date(h.checked_at).toLocaleDateString(locale) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{hosts?.map((h) => (
|
||||
<section key={h.host_id} className="mb-6 break-inside-avoid">
|
||||
<h2 className="mb-1 flex items-baseline justify-between border-b border-slate-300 pb-1 text-lg font-semibold">
|
||||
<span>{h.host_name}</span>
|
||||
<span className="text-sm font-normal text-slate-600">{h.score === null ? t("security.notChecked") : `${h.score} / 100`}</span>
|
||||
</h2>
|
||||
{failed(h).length === 0 ? (
|
||||
<p className="text-sm text-slate-600">{t("security.allGood")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{failed(h).map((f) => (
|
||||
<li key={f.id}>
|
||||
<strong>{t(`security.checks.${f.id}.title`)}</strong> <span className="text-slate-500">({t(`security.severity.${f.severity}`)})</span>
|
||||
<div className="text-slate-700">{t(`security.checks.${f.id}.fail`, { count: f.params.count ?? 0, reason: f.params.reason ? t(`security.reason.${f.params.reason}`) : "", protocol: f.params.protocol ?? "", days: f.params.age_days ?? "" })}</div>
|
||||
{(f.params.examples ?? []).length > 0 && <div className="text-slate-600">{(f.params.examples ?? []).join(" · ")}</div>}
|
||||
<div className="text-slate-500">{t(`security.checks.${f.id}.fix`)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ function NotificationsSection() {
|
||||
const [carpAutoRevertFailed, setCarpAutoRevertFailed] = useState(true);
|
||||
const [scheduledFailed, setScheduledFailed] = useState(true);
|
||||
const [backupFailed, setBackupFailed] = useState(true);
|
||||
const [complianceAlerts, setComplianceAlerts] = useState(false);
|
||||
const [pollInterval, setPollInterval] = useState(300);
|
||||
|
||||
function applyFromServer(data: NotificationSettings) {
|
||||
@@ -89,6 +90,7 @@ function NotificationsSection() {
|
||||
setCarpAutoRevertFailed(data.carp_auto_revert_failed_enabled);
|
||||
setScheduledFailed(data.scheduled_maintenance_failed_enabled);
|
||||
setBackupFailed(data.backup_failed_enabled);
|
||||
setComplianceAlerts(data.compliance_alerts_enabled);
|
||||
setPollInterval(data.poll_interval_seconds);
|
||||
setWebhookSecret("");
|
||||
setClearWebhookSecret(false);
|
||||
@@ -131,6 +133,7 @@ function NotificationsSection() {
|
||||
carp_auto_revert_failed_enabled: carpAutoRevertFailed,
|
||||
scheduled_maintenance_failed_enabled: scheduledFailed,
|
||||
backup_failed_enabled: backupFailed,
|
||||
compliance_alerts_enabled: complianceAlerts,
|
||||
poll_interval_seconds: pollInterval,
|
||||
});
|
||||
applyFromServer(data);
|
||||
@@ -264,6 +267,7 @@ function NotificationsSection() {
|
||||
[t("settings.notify.event.carpAutoRevertFailed"), carpAutoRevertFailed, setCarpAutoRevertFailed],
|
||||
[t("settings.notify.event.scheduledFailed"), scheduledFailed, setScheduledFailed],
|
||||
[t("settings.notify.event.backupFailed"), backupFailed, setBackupFailed],
|
||||
[t("settings.notify.event.complianceAlerts"), complianceAlerts, setComplianceAlerts],
|
||||
].map(([label, value, setter]) => (
|
||||
<label key={label as string} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={value as boolean} onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)} />
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
<LoginInfoBanner />
|
||||
<UpdateBanner />
|
||||
<main id="main-content" tabIndex={-1} className="mx-auto max-w-6xl px-4 py-6 outline-none sm:px-6 sm:py-8">{children}</main>
|
||||
<footer className="mx-auto max-w-6xl px-4 pb-6 text-center text-xs text-slate-400 sm:px-6">
|
||||
<footer className="mx-auto max-w-6xl px-4 pb-6 text-center text-xs text-slate-400 sm:px-6 print:hidden">
|
||||
<Link href="/about" className="hover:text-slate-500">
|
||||
{t("common.versionLabel", { version: APP_VERSION, codename: APP_CODENAME })}
|
||||
</Link>
|
||||
|
||||
@@ -216,7 +216,7 @@ export function Nav() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<header className="border-b border-slate-200 bg-white print:hidden">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3 sm:px-6">
|
||||
<Link href="/about" title={t("nav.aboutTitle")} className="shrink-0">
|
||||
<Logo className="h-6 w-auto sm:h-7" />
|
||||
|
||||
+4
-1
@@ -1757,6 +1757,7 @@ export interface NotificationSettings {
|
||||
carp_auto_revert_failed_enabled: boolean;
|
||||
scheduled_maintenance_failed_enabled: boolean;
|
||||
backup_failed_enabled: boolean;
|
||||
compliance_alerts_enabled: boolean;
|
||||
poll_interval_seconds: number;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -1786,6 +1787,7 @@ export interface NotificationSettingsUpdate {
|
||||
carp_auto_revert_failed_enabled?: boolean;
|
||||
scheduled_maintenance_failed_enabled?: boolean;
|
||||
backup_failed_enabled?: boolean;
|
||||
compliance_alerts_enabled?: boolean;
|
||||
poll_interval_seconds?: number;
|
||||
}
|
||||
|
||||
@@ -1811,7 +1813,8 @@ export type ProblemType =
|
||||
| "carp_split_brain" | "nat_drift" | "carp_auto_revert_failed" | "network_drift"
|
||||
| "scheduled_maintenance_failed"
|
||||
| "backup_stale"
|
||||
| "backup_offsite_failed";
|
||||
| "backup_offsite_failed"
|
||||
| "compliance_finding";
|
||||
|
||||
export interface CarpEvent {
|
||||
id: string;
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"carpAutoRevertFailed": "CARP-Auto-Revert fehlgeschlagen",
|
||||
"scheduledFailed": "Geplante Wartung nicht ausgeführt",
|
||||
"backupFailed": "Kein aktuelles Backup / Offsite-Kopie fehlgeschlagen",
|
||||
"complianceAlerts": "Kritischer oder hoher Sicherheitsbefund (standardmäßig aus)",
|
||||
"networkDrift": "Netzwerk-Abweichung im Cluster"
|
||||
},
|
||||
"pollInterval": "Prüfintervall (Sekunden)",
|
||||
@@ -230,6 +231,9 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"securityPosture": "Sicherheitslage",
|
||||
"postureNone": "Es wurde noch keine Sicherheitsprüfung durchgeführt.",
|
||||
"postureOpen": "Öffnen",
|
||||
"subtitle": "Fleet-Status, offene Aktionen und Sicherheitswarnungen auf einen Blick.",
|
||||
"managedHosts": "Verwaltete Hosts",
|
||||
"online": "Online",
|
||||
@@ -1607,6 +1611,16 @@
|
||||
"operatorsDeploy": "Operatoren dürfen Änderungen selbst ausrollen",
|
||||
"dutiesSaved": "Gespeichert"
|
||||
},
|
||||
"report": {
|
||||
"title": "Sicherheitsbericht",
|
||||
"generated": "Erstellt {when} · OPNexus {version}",
|
||||
"back": "Zurück",
|
||||
"print": "Drucken / als PDF speichern",
|
||||
"score": "Wert",
|
||||
"open": "Offene Befunde",
|
||||
"checked": "Geprüft",
|
||||
"open_report": "Bericht"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicherheitslage",
|
||||
"intro": "Prüft die Konfiguration jeder Firewall auf typische Schwachstellen (offene Regeln, schwaches SSH/SNMP, fehlende Backups …) und vergibt einen Wert von 0 bis 100. Nur lesend: an den Firewalls wird nichts geändert. Automatisch alle 6 Stunden geprüft.",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"carpAutoRevertFailed": "CARP auto-revert failed",
|
||||
"scheduledFailed": "Scheduled maintenance did not run",
|
||||
"backupFailed": "No recent backup / offsite copy failed",
|
||||
"complianceAlerts": "Critical or high security finding (off by default)",
|
||||
"networkDrift": "Network differences in the cluster"
|
||||
},
|
||||
"pollInterval": "Check interval (seconds)",
|
||||
@@ -230,6 +231,9 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"securityPosture": "Security posture",
|
||||
"postureNone": "No security check has run yet.",
|
||||
"postureOpen": "Open",
|
||||
"subtitle": "Fleet status, pending actions, and security warnings at a glance.",
|
||||
"managedHosts": "Managed hosts",
|
||||
"online": "Online",
|
||||
@@ -1607,6 +1611,16 @@
|
||||
"operatorsDeploy": "Operators may roll out changes themselves",
|
||||
"dutiesSaved": "Saved"
|
||||
},
|
||||
"report": {
|
||||
"title": "Security report",
|
||||
"generated": "Created {when} · OPNexus {version}",
|
||||
"back": "Back",
|
||||
"print": "Print / save as PDF",
|
||||
"score": "Score",
|
||||
"open": "Open findings",
|
||||
"checked": "Checked",
|
||||
"open_report": "Report"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security posture",
|
||||
"intro": "Checks every firewall's configuration for common weaknesses (open rules, weak SSH/SNMP, missing backups…) and gives a score from 0 to 100. Read-only: nothing is changed on the firewalls. Checked automatically every 6 hours.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Manually bumped whenever a notable change ships — mirrors the dated
|
||||
// entries already kept in the project README/CHANGELOG.md, no build
|
||||
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
|
||||
export const APP_VERSION = "1.22.0";
|
||||
export const APP_CODENAME = "Peregrine";
|
||||
export const APP_VERSION = "1.23.0";
|
||||
export const APP_CODENAME = "Harrier";
|
||||
export const APP_MAINTAINER = "Julian Boldt";
|
||||
Reference in new issue
Block a user