Compare commits

..
1 Commits
Author SHA1 Message Date
Julian Boldt c4c8c4ff3a Release v1.23.0
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
- **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).
2026-10-20 02:02:04 +02:00
24 changed files with 298 additions and 20 deletions

No files matched your search

+6
View File
@@ -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
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **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
+2 -2
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **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
+2
View File
@@ -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)
+3
View File
@@ -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:
+2 -2
View File
@@ -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")
+39
View File
@@ -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) == {}
+7
View File
@@ -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,
+24 -1
View File
@@ -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>
+14 -7
View File
@@ -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>}
+103
View File
@@ -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>
);
}
+4
View File
@@ -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)} />
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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;
+14
View File
@@ -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.",
+14
View File
@@ -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.",
+2 -2
View File
@@ -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";