Files
OPNexus/frontend/components/SystemSettingsSection.tsx
T
Julian Boldt b1a96c7563
Release / verify (push) Waiting to run
Release / test (push) Blocked by required conditions
CI / backend (push) Blocked by required conditions
CI / frontend (push) Blocked by required conditions
CI / checks (push) Blocked by required conditions
Release v1.34.0
- **Zum Update:** Das erste Update auf diese Version übernimmt auch die `docker-compose.yml` (siehe unten; braucht wie die
  CLI-Aktualisierung `cosign` und `cosign.pub`), migriert die Datenbank (Revision 0032; ein Rollback spielt die Sicherung davor
  zurück) und stellt das Backend auf einen unprivilegierten Benutzer um. Nichts davon braucht einen Handgriff.
- **Nur ein Backend führt die Hintergrunddienste aus (HA, Stufe H0):** Laufen zwei Backends an derselben Datenbank, verschickten
  beide jeden Alarm und brachen Zyklen mit `UniqueViolation` ab. Jetzt führt nur der Prozess mit der Leader-Sperre in der
  Datenbank die neun Hintergrundschleifen aus; die anderen warten in Bereitschaft und übernehmen innerhalb einer halben Minute,
  wenn der Leader ausfällt. Verliert der Leader seine Datenbankverbindung, hält er alle Schleifen sofort an (geprüft alle 5 Sekunden
  und vor jedem Durchlauf). An einer Replica startet kein Leader. Rolle in `GET /api/health` und als Metrik `opnexus_leader`.
  Zwei gleichzeitige Erststarts legen das Admin-Konto nur einmal an.
- **Umzug und Kaltreserve (HA, Stufe H1):** `opnexus backup --full --with-env --with-ca` nimmt zusätzlich die `.env` und die
  interne CA des Proxys mit, `opnexus restore DATEI --with-env --with-ca` spielt sie zurück (ohne Datenbank-Zugang, Version und
  Adressen des Zielservers; die CA bleibt dieselbe, Browser vertrauen weiter). Neu: `opnexus migrate-host BENUTZER@ZIEL` zieht
  eine Installation in einem Schritt auf einen anderen Server um (Vorprüfung, Quelle anhalten, Sicherung mit Zufalls-Passphrase,
  Restore, Kontrolle). Im Lab 90 Sekunden mit drei Firewalls. `opnexus status` zeigt das Alter der letzten Vollsicherung.
  Handbuch: „Umzug und Notfall“.
- **Betriebseinstellungen im Webinterface:** Was bisher nur in der `.env` oder gar nur im Code stand, lässt sich unter
  Einstellungen › Betriebseinstellungen ändern: Sitzungsdauer und Anmeldesperren, Bestätigungsfrist beim Ausrollen, Zeitlimit für
  Firewall-Anfragen, Messintervalle und Aufbewahrung, Intervall der Versionsprüfung, Heartbeat-Adresse, CA des Mailservers (PEM
  einfügen statt Datei einbinden), Zeitlimit Mailversand. Mit Grenzen, Standardwert, Zurücksetzen und Audit-Eintrag. Ein Wert in der
  `.env` hat weiter Vorrang und wird als „vom Server“ angezeigt. Adressen, TLS und Datenbank-Zugang bleiben bewusst in der `.env`.
- **Heartbeat für einen externen Überwacher:** Die Heartbeat-Adresse (nur `https://`) wird nach jedem erfolgreichen Prüfzyklus
  aufgerufen; bleibt der Aufruf aus, schlägt der Überwacher Alarm. Die Adresse wird nie angezeigt oder protokolliert.
- **Updates bringen die Compose-Datei mit:** Bisher tauschte `opnexus update` nur die Images; Änderungen an der
  `docker-compose.yml` (neue Variablen, Härtung) kamen bei bestehenden Installationen nie an. Jetzt übernimmt das Update die Datei
  des Zielreleases aus dem signierten Updater-Image, sichert die alte als `docker-compose.yml.bak-<version>` und legt sie beim
  Rollback zurück. Eine hier angepasste Datei wird nicht überschrieben (neue Fassung als `docker-compose.yml.new`); eigene
  Anpassungen gehören in `docker-compose.override.yml`.
- **Alarm „Platte des OPNexus-Servers fast voll“** (standardmäßig an, ab 90 %, einstellbar): eine volle Platte hält die Datenbank
  an und damit jeden anderen Alarm. Auch im Dashboard und als Metrik `opnexus_server_disk_used_percent`.
- **Härtung aus einem externen Audit:**
  - **Kein root mehr in den Containern:** Backend (uid 10001) und Frontend (`node`) laufen unprivilegiert, ohne neue Rechte (`no-new-privileges`), das Frontend zusätzlich
    ohne Capabilities. Das Backend startet über `backend/scripts/start.sh` noch kurz als root, stellt die vorhandenen Datenvolumes einmalig um und gibt dann alle Rechte ab:
    ein Update braucht keinen Handgriff, ein Rückweg auf eine ältere Version bleibt möglich. Notausgang für Sonderfälle: `OPNEXUS_RUN_AS_ROOT=1` in der `.env`.
    `wg-hub` (braucht root für WireGuard) und `gui-gateway` (läuft schon als nobody) sind unverändert. Der Proxy (Caddy) und der optionale Updater (Docker-Socket) laufen weiter wie bisher.
  - **Vollsicherung konsistent:** Ändern sich Geheimnisse, während die Sicherung läuft (jemand legt gerade ein Objekt an oder löscht eines), wird sie bis zu dreimal wiederholt;
    gelingt das nicht, bricht sie mit einer klaren Meldung ab, statt ein unstimmiges Archiv zu schreiben.
  - **Zieladress-Schutz überall:** Audit-Export (Webhook und Syslog), Offsite-S3 und der Update-Feed verbinden nur noch zu Adressen, die die Prüfung bestanden haben
    (kein Loopback, Link-Local, Metadaten-Dienst; auch nicht über DNS-Rebinding oder Weiterleitungen). Adressen im eigenen Netz bleiben erlaubt. Der Update-Feed wird beim Lesen auf 1 MB begrenzt, nicht erst danach.
- **Fehlende Basis-Rechte werden sichtbar:** Fehlen dem API-Schlüssel schon die Rechte für die Grundmessungen (Speicher, Last, Platte), schlug das ganze Sampling still fehl und der
  Rechte-Alarm konnte nie auslösen. Jetzt entsteht ein Befund „Grundwerte“ (Recht „Lobby: Dashboard“), der sich nach Korrektur von selbst schließt.
- **Installation:** Die `INSTALL.txt` im Bundle und die Anleitung auf der Website legen die `.env` jetzt mit `install -m 600` an (nur für dich lesbar), statt mit `cp`.
- **Oberfläche:** Die Aktionsknöpfe der Backup-Tabelle auf der Host-Seite werden auf schmalen Bildschirmen als Symbole gezeigt, statt abgeschnitten zu werden.
2026-10-24 18:55:10 +02:00

230 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Field";
import { useToast } from "@/components/ui/Toast";
import { InfoTip } from "@/components/ui/Tooltip";
import { useLanguage } from "@/components/LanguageProvider";
import { api, SystemSetting } from "@/lib/api";
import { useNotAdmin } from "@/lib/useReadOnly";
import { RotateCcw } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
const GROUPS = ["security", "operation", "monitoring", "integrations"] as const;
type Draft = Record<string, string>;
function draftOf(items: SystemSetting[]): Draft {
const draft: Draft = {};
for (const item of items) {
draft[item.key] = item.kind === "secret_url" ? "" : item.value === null ? "" : String(item.value);
}
return draft;
}
/** Settings › Betrieb: everything that used to need the server's .env and is safe to change while OPNexus runs. */
export function SystemSettingsSection() {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const readOnly = useNotAdmin();
const [items, setItems] = useState<SystemSetting[] | null>(null);
const [draft, setDraft] = useState<Draft>({});
const [saving, setSaving] = useState<string | null>(null);
const [error, setError] = useState("");
function apply(next: SystemSetting[]) {
setItems(next);
setDraft(draftOf(next));
}
useEffect(() => {
api.systemSettings().then((body) => apply(body.settings)).catch((e) => setError(errorMessage(e)));
}, [errorMessage]);
const byGroup = useMemo(() => {
const out: Record<string, SystemSetting[]> = {};
for (const item of items ?? []) (out[item.group] ??= []).push(item);
return out;
}, [items]);
function changed(item: SystemSetting): boolean {
if (item.source === "server") return false;
const value = draft[item.key] ?? "";
if (item.kind === "secret_url") return value.trim() !== "";
return value !== (item.value === null ? "" : String(item.value));
}
async function save(values: Record<string, number | string | null>, label: string) {
setSaving(label);
setError("");
try {
apply((await api.saveSystemSettings(values)).settings);
toast.show(t("settings.system.saved"), "success");
} catch (e) {
setError(errorMessage(e, "common.saveFailed"));
} finally {
setSaving(null);
}
}
function saveGroup(group: string) {
const values: Record<string, number | string | null> = {};
for (const item of byGroup[group] ?? []) {
if (!changed(item)) continue;
const raw = (draft[item.key] ?? "").trim();
values[item.key] = item.kind === "int" || item.kind === "float" ? (raw === "" ? null : Number(raw)) : raw;
}
if (Object.keys(values).length) void save(values, group);
}
function sourceBadge(item: SystemSetting) {
if (item.source === "server") {
return <Badge tone="warning">{t("settings.system.fromServer", { env: item.env ?? "" })}</Badge>;
}
if (item.source === "ui") return <Badge tone="info">{t("settings.system.changed")}</Badge>;
return null;
}
function field(item: SystemSetting) {
const label = t(`settings.system.field.${item.key}.label`);
const hint = t(`settings.system.field.${item.key}.hint`);
const locked = readOnly || item.source === "server";
const value = draft[item.key] ?? "";
const set = (v: string) => setDraft((d) => ({ ...d, [item.key]: v }));
const reset = item.source === "ui" && !readOnly && (
<Button
variant="ghost"
type="button"
title={t("settings.system.reset")}
aria-label={`${t("settings.system.reset")}: ${label}`}
disabled={saving !== null}
onClick={() => void save({ [item.key]: null }, item.key)}
>
<RotateCcw size={14} />
</Button>
);
const head = (
<span className="mb-1 flex flex-wrap items-center gap-1.5 font-medium text-slate-700">
{label}
<InfoTip text={hint} size={13} />
{sourceBadge(item)}
</span>
);
if (item.kind === "pem") {
return (
<div key={item.key} className="block text-sm sm:col-span-2">
{head}
{item.source === "server" ? (
<p className="text-slate-600">{t("settings.system.pemFromServer")}</p>
) : (
<textarea
aria-label={label}
className="h-28 w-full rounded-lg border border-slate-300 bg-white p-2 font-mono text-xs"
placeholder="-----BEGIN CERTIFICATE-----"
spellCheck={false}
disabled={locked}
value={value}
onChange={(e) => set(e.target.value)}
/>
)}
{reset && <div className="mt-1 flex justify-end">{reset}</div>}
</div>
);
}
if (item.kind === "secret_url") {
return (
<div key={item.key} className="block text-sm sm:col-span-2">
{head}
<div className="flex gap-2">
<Input
aria-label={label}
type="url"
inputMode="url"
spellCheck={false}
autoComplete="off"
disabled={locked}
placeholder={item.is_set ? t("settings.system.secretSet") : "https://"}
value={value}
onChange={(e) => set(e.target.value)}
/>
{item.is_set && item.source === "ui" && !readOnly && (
<Button variant="secondary" type="button" disabled={saving !== null} onClick={() => void save({ [item.key]: null }, item.key)}>
{t("settings.system.remove")}
</Button>
)}
</div>
</div>
);
}
const bounds = item.min !== null && item.max !== null ? t("settings.system.bounds", { min: item.min, max: item.max }) : "";
return (
<label key={item.key} className="block text-sm">
{head}
<div className="flex items-center gap-2">
<Input
type="number"
min={item.min ?? undefined}
max={item.max ?? undefined}
step={item.kind === "float" ? 0.5 : 1}
disabled={locked}
value={value}
onChange={(e) => set(e.target.value)}
/>
<span className="shrink-0 text-xs text-slate-500">{item.unit ? t(`settings.system.unit.${item.unit}`) : ""}</span>
{reset}
</div>
<span className="mt-1 block text-xs text-slate-500">
{item.default !== null && t("settings.system.defaultIs", { value: item.default })}
{bounds && ` · ${bounds}`}
</span>
</label>
);
}
return (
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 text-lg font-medium">
{t("settings.operational")}
<InfoTip text={t("settings.system.intro")} />
</h2>
{!items && !error && <p className="text-sm text-slate-500">{t("common.loading")}</p>}
{items &&
GROUPS.filter((g) => byGroup[g]?.length).map((group) => {
const dirty = (byGroup[group] ?? []).some(changed);
return (
<form
key={group}
className="space-y-3 rounded-lg border border-slate-100 p-4"
onSubmit={(e) => {
e.preventDefault();
saveGroup(group);
}}
>
<p className="flex items-center gap-2 text-sm font-medium">
{t(`settings.system.group.${group}`)}
<InfoTip text={t(`settings.system.groupHint.${group}`)} />
</p>
<div className="grid gap-4 sm:grid-cols-2">{(byGroup[group] ?? []).map(field)}</div>
{!readOnly && (
<div className="flex justify-end border-t border-slate-100 pt-3">
<Button type="submit" disabled={!dirty || saving !== null}>
{t("common.save")}
</Button>
</div>
)}
</form>
);
})}
{error && (
<p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">
{error}
</p>
)}
</section>
);
}