- **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.
230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|