Files
OPNexus/frontend/components/ManagedCertificateForm.tsx
T
Julian Boldt 1fc2d2c87c
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 v1.11.1
Fehler aus dem ersten Live-Test der bisher nur gemockten Funktionen auf drei
echten OPNsense-Firewalls (alle in den Tests mit Mocks unsichtbar geblieben).
Live bestätigt sind jetzt auch das Major-Upgrade über den Update Center
(25.7 → 26.1 → 26.7, je mit Backup und Abschluss-Bestätigung), WireGuard,
OpenVPN, IPsec, Zertifikate, ACME (Staging), hierarchische Policies,
Benachrichtigungen und der HA-Config-Sync:

- **Zertifikate (Trust-Store) und ACME ließen sich nicht pushen:** Die URLs
  endeten auf `…Action` (`/trust/cert/addAction`), OPNsense kennt aber nur
  `/trust/cert/add`, `set`, `del` bzw. `/acmeclient/…/add`, `update`, `del` (404).
  Die Mocks hatten denselben Tippfehler. Ein neuer Test prüft den Client-Quelltext
  gegen dieses Muster.
- **OpenVPN-Deploy scheiterte immer:** Die Firewall verlangt `vpnid` und vergibt
  es nicht selbst. OPNexus wählt jetzt die nächste freie. Außerdem erwarten
  `cert`/`ca` (OpenVPN) und `caref` (Zertifikat signieren) die **refid** der
  Trust-Store-Einträge, der Picker lieferte die Zeilen-UUID. Die Listen
  `/hosts/{id}/certificates` und `/cas` liefern jetzt `refid`; im
  Zertifikatsformular gibt es ein CA-Dropdown statt eines Freitextfelds.
- **WireGuard meldete „Erfolg“ ohne laufenden Tunnel:** Ist der globale Dienst
  (VPN › WireGuard › Allgemein) aus, entsteht trotz geschriebener Konfiguration
  kein Interface. Der Deploy schaltet den Dienst jetzt ein.
- **Dienststeuerung:** Das Backend prüfte weder, ob der Dienst existiert, noch ob
  er gesperrt ist (pf, webgui, routing …). Beides war nur in der UI gesperrt;
  unbekannte Namen meldete die Firewall trotzdem als „ok“. Jetzt 404
  `SERVICE_NOT_FOUND` bzw. 409 `SERVICE_LOCKED`.
- **Fehlertexte der Firewall unleserlich:** Die Redaktion maskierte jede
  Validierungsmeldung, deren Feldname `cert`, `crt` oder `auth` enthält
  („instance.cert: [redacted]“). In `validations` lösen diese drei Namen die
  Maskierung nicht mehr aus; echte Geheimnisfelder bleiben maskiert. Das
  Löschen eines benutzten Zertifikats nennt jetzt den Grund der Firewall
  („Item in use by OpenVPN …“) statt eines allgemeinen Fehlers.
- **Drift-Alarme flackerten beim Neustart eines Cluster-Mitglieds:** Mit nur
  einem erreichbaren Host gab es nichts zu vergleichen, offene Regel-/NAT-Drift-
  und Zertifikatsprobleme wurden „gelöst“ und danach neu geöffnet (je ein
  Meldungspaar pro Problem). Offene Probleme bleiben jetzt unverändert, solange
  sie nicht beurteilbar sind.
- **IPsec lief nach dem Deploy nicht:** Der globale IPsec-Schalter war aus, die
  Verbindungen wurden gespeichert, `reconfigure` antwortete „ok“, OPNexus meldete
  „Erfolg“ (gleiche Klasse wie bei WireGuard). Der Deploy schaltet ihn jetzt ein;
  zwischen zwei Test-FWs steht der Tunnel (Phase 1 verbunden).
- **Löschen scheiterte ohne Grund:** Wenn die Firewall ein Löschen ablehnt (Alias
  noch in einer Regel, Zertifikat in Benutzung), nennt die Meldung jetzt den Grund
  der Firewall (`UPSTREAM_REJECTED`) statt „Anfrage fehlgeschlagen“.
- **Kaputte Eingaben:** Ein NUL-Zeichen in einem Textfeld ergab einen 500er samt
  abgebrochener Verbindung, ebenso ein langes oder NUL-haltiges Pfadsegment (der
  Audit-Eintrag übernahm es ungeprüft in `VARCHAR(36)`). Jetzt 422 bzw. 404/422;
  ein Fuzz-Lauf über alle 188 Routen findet keine 5xx mehr.
- **`opnexus rollback` landete auf der falschen Version:** Zurückgesetzt wurde auf
  die vom Backend gemeldete Programmversion statt auf den vorher eingestellten
  Image-Tag. Bei Release-Candidates (rc1-Images melden schon `1.11.0`) führte das
  auf das neuere Release. Jetzt wird der vorherige exakte Tag wiederhergestellt.
- **Handy-Ansicht (375px) zoomte aus:** Die ausgeblendete Überschrift der
  Aktionsspalte (`sr-only`, absolut positioniert) lag außerhalb der scrollbaren
  Tabellenkarte und machte die Seite bis zu doppelt so breit (Hosts, Benutzer,
  Host-Details). Scroll-Container sind jetzt der Bezugsrahmen.
- **Überschriften-Reihenfolge:** Kartentitel in den Listen (OpenVPN, IPsec,
  WireGuard, NAT) sind `h2` statt `h3`, sichtbar erst mit Daten; axe meldet auf
  allen Seiten, hell und dunkel, nichts mehr.

- **CARP-Wartungsfenster mit Auto-Revert:** Nach dem automatischen Zurückschalten
  blieb die Aktion „CARP-Übergabe“ (`carp_enter`) offen. Sie ließ sich nicht mehr
  bestätigen (der Host ist ja nicht mehr im Wartungsmodus) und blockierte alle
  Deploys und Updates im Cluster (`OPEN_HOST_MAINTENANCE_BLOCKS_DEPLOY`), bis
  jemand sie von Hand abbrach. Der Auto-Revert schließt die Aktion jetzt selbst
  (Status „abgebrochen“, Audit-Eintrag). Schlägt das Zurückschalten fehl, bleibt
  sie bewusst offen. Gefunden beim Live-Test gegen zwei OPNsense-26.7-Firewalls.
- **Update Center, Protokoll-Fenster:** Nach einem Update mit Neustart ist das
  Protokoll der Firewall leer; angezeigt wurde dann nur das Wort „error“, obwohl
  das Update erfolgreich war. Jetzt steht dort ein erklärender Hinweis.
2026-10-09 18:35:53 +02:00

218 lines
9.5 KiB
TypeScript

"use client";
import { Button } from "@/components/ui/Button";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { api, CertAction, CertDigest, CertKeyType, Host, HostCa, ManagedCertificate, ManagedCertificateCreate } from "@/lib/api";
import { useLanguage } from "@/components/LanguageProvider";
import { FormEvent, useEffect, useState } from "react";
const certActions: CertAction[] = ["import", "internal", "external", "sign_csr", "import_csr", "reissue", "manual"];
const dnActions: CertAction[] = ["internal", "external", "reissue"];
const csrActions: CertAction[] = ["sign_csr"];
const crtActions: CertAction[] = ["import", "import_csr", "manual"];
const textareaClass =
"w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-xs text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500";
function emptyForm(hostId: string): ManagedCertificateCreate {
return {
host_id: hostId,
action: "import",
description: "",
commonname: "",
country: "DE",
state: "",
city: "",
organization: "",
email: "",
key_type: "2048",
digest: "sha256",
cert_type: "server_cert",
lifetime: 397,
caref: null,
csr_payload: null,
crt_payload: null,
private_key: "",
};
}
export function ManagedCertificateForm({ hosts, editing, onClose, onSaved }: {
hosts: Host[]; editing: ManagedCertificate | null; onClose: () => void; onSaved: () => void;
}) {
const { t, errorMessage } = useLanguage();
const [form, setForm] = useState<ManagedCertificateCreate>(
editing ? { ...editing, private_key: "" } : emptyForm(hosts[0]?.id ?? "")
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// The firewall expects the CA's refid in caref (not its row UUID), so the
// CAs of the selected host are offered as a list; if that lookup fails the
// field stays a free text input.
const [cas, setCas] = useState<HostCa[] | null>(null);
useEffect(() => {
let cancelled = false;
setCas(null);
if (!form.host_id) return;
api.getHostCas(form.host_id).then((list) => { if (!cancelled) setCas(list); }).catch(() => { if (!cancelled) setCas(null); });
return () => { cancelled = true; };
}, [form.host_id]);
async function submit(e: FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
if (editing) {
const { private_key, ...rest } = form;
await api.editManagedCertificate(editing.id, private_key ? form : { ...rest, private_key: null }, editing.updated_at);
} else {
await api.createManagedCertificate(form);
}
onSaved();
} catch (e) {
setError(errorMessage(e, "common.saveFailed"));
} finally {
setSaving(false);
}
}
return (
<Modal title={editing ? t("certForm.edit") : t("certForm.new")} onClose={onClose}>
<form onSubmit={submit} className="space-y-4">
{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
<FieldGroup label={t("certForm.host")}>
<Select value={form.host_id} onChange={(e) => setForm({ ...form, host_id: e.target.value })} disabled={!!editing} required>
{hosts.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
</Select>
</FieldGroup>
<FieldGroup label={t("certForm.action")}>
<Select value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value as CertAction })}>
{certActions.map((value) => <option key={value} value={value}>{t(`certAction.${value}`)}</option>)}
</Select>
</FieldGroup>
<FieldGroup label={t("certForm.description")}>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} required />
</FieldGroup>
{dnActions.includes(form.action) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FieldGroup label={t("certForm.commonName")}>
<Input value={form.commonname} onChange={(e) => setForm({ ...form, commonname: e.target.value })} required placeholder="host.example.com" />
</FieldGroup>
<FieldGroup label={t("certForm.country")}>
<Input value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value.toUpperCase() })} maxLength={2} />
</FieldGroup>
<FieldGroup label={t("certForm.state")}>
<Input value={form.state} onChange={(e) => setForm({ ...form, state: e.target.value })} />
</FieldGroup>
<FieldGroup label={t("certForm.city")}>
<Input value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} />
</FieldGroup>
<FieldGroup label={t("certForm.organization")}>
<Input value={form.organization} onChange={(e) => setForm({ ...form, organization: e.target.value })} />
</FieldGroup>
<FieldGroup label={t("certForm.email")}>
<Input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</FieldGroup>
</div>
)}
{dnActions.includes(form.action) && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<FieldGroup label={t("certForm.keyType")}>
<Select value={form.key_type} onChange={(e) => setForm({ ...form, key_type: e.target.value as CertKeyType })}>
<option value="2048">RSA 2048</option>
<option value="3072">RSA 3072</option>
<option value="4096">RSA 4096</option>
<option value="prime256v1">EC prime256v1</option>
<option value="secp384r1">EC secp384r1</option>
<option value="secp521r1">EC secp521r1</option>
</Select>
</FieldGroup>
<FieldGroup label={t("certForm.digest")}>
<Select value={form.digest} onChange={(e) => setForm({ ...form, digest: e.target.value as CertDigest })}>
<option value="sha256">SHA256</option>
<option value="sha384">SHA384</option>
<option value="sha512">SHA512</option>
</Select>
</FieldGroup>
<FieldGroup label={t("certForm.lifetime")}>
<Input type="number" min={1} max={36500} value={form.lifetime} onChange={(e) => setForm({ ...form, lifetime: Number(e.target.value) })} />
</FieldGroup>
</div>
)}
{(form.action === "internal" || form.action === "sign_csr") && (
<FieldGroup label={form.action === "sign_csr" ? t("certForm.carefRequired") : t("certForm.carefOptional")}>
{cas && cas.length > 0 ? (
<Select
value={form.caref ?? ""}
onChange={(e) => setForm({ ...form, caref: e.target.value || null })}
required={form.action === "sign_csr"}
>
<option value="">{form.action === "sign_csr" ? t("certForm.carefChoose") : t("certForm.carefSelfSigned")}</option>
{cas.map((c) => <option key={c.uuid} value={c.refid}>{c.descr}{c.common_name ? ` (${c.common_name})` : ""}</option>)}
</Select>
) : (
<Input
value={form.caref ?? ""}
onChange={(e) => setForm({ ...form, caref: e.target.value || null })}
required={form.action === "sign_csr"}
placeholder={t("certForm.carefPlaceholder")}
/>
)}
</FieldGroup>
)}
{csrActions.includes(form.action) && (
<FieldGroup label={t("certForm.csr")}>
<textarea
className={textareaClass}
rows={6}
value={form.csr_payload ?? ""}
onChange={(e) => setForm({ ...form, csr_payload: e.target.value })}
placeholder="-----BEGIN CERTIFICATE REQUEST-----"
aria-label={t("certForm.csr")}
required
/>
</FieldGroup>
)}
{crtActions.includes(form.action) && (
<FieldGroup label={t("certForm.certificate")}>
<textarea
className={textareaClass}
rows={6}
value={form.crt_payload ?? ""}
onChange={(e) => setForm({ ...form, crt_payload: e.target.value })}
placeholder="-----BEGIN CERTIFICATE-----"
aria-label={t("certForm.certificate")}
required
/>
</FieldGroup>
)}
{form.action === "import" && (
<FieldGroup label={editing?.private_key_set ? t("certForm.privateKeyKeep") : t("certForm.privateKeyOptional")} hint={t("certForm.privateKeyNote")}>
<textarea
className={textareaClass}
rows={4}
value={form.private_key ?? ""}
onChange={(e) => setForm({ ...form, private_key: e.target.value })}
placeholder="-----BEGIN PRIVATE KEY-----"
aria-label={t("certForm.privateKeyAria")}
/>
</FieldGroup>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={saving}>{t("common.cancel")}</Button>
<Button type="submit" disabled={saving} loading={saving}>{saving ? t("common.saving") : t("common.save")}</Button>
</div>
</form>
</Modal>
);
}