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
- **Regel-Analyse: Bereinigung (nur Ansicht):** Auf **Regelwerk › Regel-Analyse** gibt es den Tab **Bereinigung** (`?tab=cleanup`). Er listet je Firewall die
Regeln, die einen Blick wert sind: **ungenutzt** (OPNexus hat in 7/30/90/180 Tagen keinen Treffer gesehen), **verdeckt** (eine frühere Quick-Regel
trifft schon alles, was sie trifft), **doppelt** und **lange deaktiviert**. Jeder Befund trägt eine Sicherheit (**sicher / vermutlich / unbekannt**, Tooltip mit
dem Grund): „sicher“ gibt es nur mit vollständigen, frischen Belegen, bei einer Regel mit Zeitplan, ohne geladenen pf-Regelsatz oder bei Aliasen bleibt es
höchstens „vermutlich“. Die Tabelle zeigt auch den letzten Treffer und ob die Regel entfernbar wäre (nicht: automatische Regeln, Regeln im Altformat, von OPNexus
verwaltete Regeln – die entfernt man über die Regelseite). Es wird nur gelesen; **Entfernen folgt in einer späteren Version**. Die Sicherheitslage verlinkt ihre
Befunde „ungenutzt/verdeckt/doppelt“ direkt dorthin; per API `GET /api/hosts/{id}/rule-analysis/cleanup?unused_days=30`, per MCP `rule_cleanup_candidates`.
- **Änderungsverlauf je Regel:** OPNexus liest alle 15 Minuten die Regeln jeder Firewall und merkt sich, was sich geändert hat – auch bei Regeln, die nicht von OPNexus
stammen: angelegt, geändert (mit Vorher/Nachher), aktiviert, deaktiviert, entfernt, jeweils mit der Herkunft „über OPNexus“ (passender Audit-Eintrag) oder „extern“.
Auf der Live-Regel-Ansicht (Regelwerk › Live über alle Hosts) öffnet die Zeilenaktion **Verlauf** die Ereignisse zusammen mit den gespeicherten Fassungen und dem Audit der
verwalteten Regel. Die erste Beobachtung speichert nur den Ausgangszustand; der Verlauf wird 365 Tage aufbewahrt (`GET /api/hosts/{id}/live-rules/{uuid}/history`).
Eine Umsortierung (andere Reihenfolge oder Gruppe) gilt als „verschoben“, nicht als Änderung. Eine leere oder unvollständige Antwort der Firewall (ohne
automatische Regeln) wird verworfen; verschwinden mehr als die Hälfte der Regeln auf einmal, zählt erst die zweite gleiche Beobachtung. Die Herkunft „über OPNexus“
erklärt nur Audit-Einträge seit der vorherigen Beobachtung, und jeder Eintrag nur eine Änderung.
Schemaänderungen 0027 und 0028 (Tabellen und ein Feld für den Beobachtungszeitraum) laufen beim Start.
- **Regelnutzung genauer:** Die Zähler kommen jetzt aus `ruleStats` (von der Firewall schon je Regel summiert; ältere Versionen weiterhin über den pf-Regelsatz) und werden alle
15 Minuten statt alle 6 Stunden gelesen. Regeln im Altformat (noch nicht migriert) werden ebenfalls erfasst. Das Beobachtungsfenster für „ungenutzt“ beginnt neu, wenn eine Regel eingeschaltet oder geändert wird, und nach einem pf-Neuladen mit langer Lücke bei den Zählern.
- **Verdeckt/doppelt in der Sicherheitslage genauer:** Der Vergleich kennt jetzt die echte Reihenfolge (Floating vor Interface-Gruppen vor Interface-Regeln), Netze und
Portbereiche als Mengen (z. B. 10.1.0.0/16 liegt in 10.0.0.0/8), `inet46` und `tcp/udp`, und übergeht Regeln mit Zeitplan, Markierung oder Limits. Es findet dadurch
mehr verdeckte Regeln als bisher, nie weniger belegte.
282 lines
15 KiB
TypeScript
282 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useLanguage } from "@/components/LanguageProvider";
|
|
import { RuleAnalysisCard } from "@/components/RuleAnalysisCard";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
import { FieldGroup, Input, Label, Select } from "@/components/ui/Field";
|
|
import { RuleCleanup } from "@/components/RuleCleanup";
|
|
import { Loading } from "@/components/ui/Loading";
|
|
import { Tabs } from "@/components/ui/Tabs";
|
|
import { InfoTip } from "@/components/ui/Tooltip";
|
|
import { AnalysisResult, api, DeviceGroup, Host, InterfaceOption } from "@/lib/api";
|
|
import { Network, Search } from "lucide-react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { FormEvent, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
type Scope = "all" | "hosts" | "group";
|
|
interface FormState {
|
|
scope: Scope; hostIds: string[]; groupId: string; src: string; dst: string; proto: string;
|
|
dport: string; sport: string; icmptype: string; iface: string; egress: boolean; egressIface: string;
|
|
}
|
|
|
|
const PROTOCOLS = ["tcp", "udp", "icmp", "ipv6-icmp", "gre", "esp", "ah", "ospf", "vrrp", "carp"];
|
|
|
|
function fromParams(params: URLSearchParams): FormState {
|
|
const hosts = (params.get("hosts") ?? "").split(",").filter(Boolean);
|
|
const group = params.get("group") ?? "";
|
|
const egress = params.get("egress") ?? "";
|
|
return {
|
|
scope: group ? "group" : hosts.length ? "hosts" : "all", hostIds: hosts, groupId: group,
|
|
src: params.get("src") ?? "", dst: params.get("dst") ?? "", proto: params.get("proto") ?? "tcp",
|
|
dport: params.get("dport") ?? "", sport: params.get("sport") ?? "", icmptype: params.get("icmptype") ?? "",
|
|
iface: params.get("iface") ?? "auto", egress: egress !== "" && egress !== "none", egressIface: egress && egress !== "none" ? egress : "auto",
|
|
};
|
|
}
|
|
|
|
function toParams(form: FormState): URLSearchParams {
|
|
const q = new URLSearchParams();
|
|
q.set("src", form.src.trim());
|
|
q.set("dst", form.dst.trim());
|
|
q.set("proto", (form.proto || "tcp").trim().toLowerCase());
|
|
const proto = (form.proto || "tcp").trim().toLowerCase();
|
|
const ported = ["tcp", "udp", "6", "17"].includes(proto);
|
|
const icmp = ["icmp", "ipv6-icmp", "1", "58"].includes(proto);
|
|
if (ported && form.dport.trim()) q.set("dport", form.dport.trim());
|
|
if (ported && form.sport.trim()) q.set("sport", form.sport.trim());
|
|
if (icmp && form.icmptype.trim()) q.set("icmptype", form.icmptype.trim());
|
|
if (form.iface && form.iface !== "auto") q.set("iface", form.iface);
|
|
if (form.egress) q.set("egress", form.egressIface || "auto");
|
|
if (form.scope === "group" && form.groupId) q.set("group", form.groupId);
|
|
if (form.scope === "hosts" && form.hostIds.length) q.set("hosts", form.hostIds.join(","));
|
|
return q;
|
|
}
|
|
|
|
export default function AnalysisPage() {
|
|
const { t } = useLanguage();
|
|
return <Suspense fallback={<Loading>{t("common.loading")}</Loading>}><AnalysisShell /></Suspense>;
|
|
}
|
|
|
|
/** Title and tabs (?tab=query|cleanup); each tab keeps its own state in the address bar. */
|
|
function AnalysisShell() {
|
|
const { t } = useLanguage();
|
|
const router = useRouter();
|
|
const params = useSearchParams();
|
|
const tab = params.get("tab") === "cleanup" ? "cleanup" : "query";
|
|
return (
|
|
<div>
|
|
<h1 className="mb-4 flex items-center gap-2 text-2xl font-semibold text-slate-900">
|
|
{t("analysis.title")}
|
|
<InfoTip text={t("analysis.intro")} size={18} />
|
|
</h1>
|
|
<Tabs
|
|
className="mb-6"
|
|
items={[{ key: "query", label: t("analysis.tabs.query") }, { key: "cleanup", label: t("analysis.tabs.cleanup") }]}
|
|
active={tab}
|
|
onChange={(key) => router.push(key === "cleanup" ? "/analysis?tab=cleanup" : "/analysis")}
|
|
/>
|
|
<div role="tabpanel">{tab === "cleanup" ? <RuleCleanup /> : <AnalysisContent />}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AnalysisContent() {
|
|
const { t, errorMessage } = useLanguage();
|
|
const router = useRouter();
|
|
const params = useSearchParams();
|
|
const key = params.toString();
|
|
const [form, setForm] = useState<FormState>(() => fromParams(params));
|
|
const [hosts, setHosts] = useState<Host[] | null>(null);
|
|
const [groups, setGroups] = useState<DeviceGroup[]>([]);
|
|
const [interfaces, setInterfaces] = useState<InterfaceOption[]>([]);
|
|
const [results, setResults] = useState<AnalysisResult[] | null>(null);
|
|
const [running, setRunning] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [tick, setTick] = useState(0);
|
|
const refreshNext = useRef(false);
|
|
const counter = useRef(0);
|
|
|
|
useEffect(() => {
|
|
api.listHosts().then(setHosts).catch((e) => { setHosts([]); setError(errorMessage(e, "analysis.loadFailed")); });
|
|
api.listGroups().then(setGroups).catch(() => setGroups([]));
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Keep the form in step with the address bar (back/forward, shared links).
|
|
useEffect(() => { setForm(fromParams(new URLSearchParams(key))); }, [key]);
|
|
|
|
const enabledHosts = useMemo(() => (hosts ?? []).filter((h) => h.enabled), [hosts]);
|
|
const scopedHosts = useMemo(() => {
|
|
if (form.scope === "hosts") return enabledHosts.filter((h) => form.hostIds.includes(h.id));
|
|
if (form.scope === "group") { const g = groups.find((x) => x.id === form.groupId); return enabledHosts.filter((h) => g?.host_ids.includes(h.id)); }
|
|
return enabledHosts;
|
|
}, [enabledHosts, form.scope, form.hostIds, form.groupId, groups]);
|
|
|
|
// Interface choices: the union over the firewalls in scope (names differ per firewall; an unknown one is rejected per host).
|
|
const scopeKey = scopedHosts.map((h) => h.id).join(",");
|
|
useEffect(() => {
|
|
let active = true;
|
|
Promise.allSettled(scopedHosts.map((h) => api.getHostInterfaces(h.id))).then((all) => {
|
|
if (!active) return;
|
|
const merged = new Map<string, InterfaceOption>();
|
|
for (const item of all) if (item.status === "fulfilled") for (const o of item.value) if (!merged.has(o.name)) merged.set(o.name, o);
|
|
setInterfaces([...merged.values()]);
|
|
});
|
|
return () => { active = false; };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [scopeKey]);
|
|
|
|
// Run whenever the URL carries a query (initial load, submit, shared link).
|
|
useEffect(() => {
|
|
const p = new URLSearchParams(key);
|
|
const src = p.get("src"); const dst = p.get("dst");
|
|
if (!src || !dst) { setResults(null); setError(null); return; }
|
|
const mine = ++counter.current;
|
|
const refresh = refreshNext.current;
|
|
refreshNext.current = false;
|
|
setRunning(true);
|
|
setError(null);
|
|
api.analyzeRule({
|
|
src, dst, proto: p.get("proto") ?? undefined, dport: p.get("dport") ?? undefined, sport: p.get("sport") ?? undefined,
|
|
icmptype: p.get("icmptype") ?? undefined, iface: p.get("iface") ?? undefined, egress: p.get("egress") ?? undefined,
|
|
host_ids: p.get("hosts") ?? undefined, group_id: p.get("group") ?? undefined, refresh,
|
|
})
|
|
.then((list) => { if (mine === counter.current) setResults(list); })
|
|
.catch((e) => { if (mine === counter.current) { setResults(null); setError(errorMessage(e, "analysis.queryFailed")); } })
|
|
.finally(() => { if (mine === counter.current) setRunning(false); });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [key, tick]);
|
|
|
|
function submit(event?: FormEvent, refresh = false) {
|
|
event?.preventDefault();
|
|
if (!form.src.trim() || !form.dst.trim()) { setFormError(t("analysis.form.missing")); return; }
|
|
if (form.scope === "hosts" && form.hostIds.length === 0) { setFormError(t("analysis.form.noHostsSelected")); return; }
|
|
if (form.scope === "group" && !form.groupId) { setFormError(t("analysis.form.noGroupSelected")); return; }
|
|
setFormError(null);
|
|
const next = toParams(form).toString();
|
|
refreshNext.current = refresh;
|
|
if (next === key) setTick((n) => n + 1);
|
|
else router.push(`/analysis?${next}`);
|
|
}
|
|
|
|
const set = <K extends keyof FormState>(field: K, value: FormState[K]) => setForm((f) => ({ ...f, [field]: value }));
|
|
const proto = (form.proto || "tcp").trim().toLowerCase();
|
|
const ported = ["tcp", "udp", "6", "17"].includes(proto);
|
|
const icmp = ["icmp", "ipv6-icmp", "1", "58"].includes(proto);
|
|
const ifaceChoices = (current: string) => (current && current !== "auto" && !interfaces.some((o) => o.name === current) ? [...interfaces, { name: current, label: current }] : interfaces);
|
|
|
|
return (
|
|
<div>
|
|
<Card className="mb-6 p-4 sm:p-5">
|
|
<form onSubmit={submit} aria-label={t("analysis.form.title")} className="flex flex-col gap-4" noValidate>
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<FieldGroup label={t("analysis.form.scope")}>
|
|
<Select value={form.scope} onChange={(e) => set("scope", e.target.value as Scope)}>
|
|
<option value="all">{t("analysis.form.scopeAll")}</option>
|
|
<option value="hosts">{t("analysis.form.scopeHosts")}</option>
|
|
<option value="group">{t("analysis.form.scopeGroup")}</option>
|
|
</Select>
|
|
</FieldGroup>
|
|
{form.scope === "group" && (
|
|
<FieldGroup label={t("analysis.form.group")}>
|
|
<Select value={form.groupId} onChange={(e) => set("groupId", e.target.value)}>
|
|
<option value="">-</option>
|
|
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
|
</Select>
|
|
</FieldGroup>
|
|
)}
|
|
<FieldGroup label={t("analysis.form.src")} hint={t("analysis.form.srcHint")}>
|
|
<Input value={form.src} onChange={(e) => set("src", e.target.value)} inputMode="text" autoComplete="off" spellCheck={false} placeholder="198.51.100.7" className="font-mono" />
|
|
</FieldGroup>
|
|
<FieldGroup label={t("analysis.form.dst")}>
|
|
<Input value={form.dst} onChange={(e) => set("dst", e.target.value)} autoComplete="off" spellCheck={false} placeholder="203.0.113.9" className="font-mono" />
|
|
</FieldGroup>
|
|
<FieldGroup label={t("analysis.form.proto")} hint={t("analysis.form.protoHint")}>
|
|
<Input value={form.proto} onChange={(e) => set("proto", e.target.value)} list="analysis-protocols" autoComplete="off" spellCheck={false} />
|
|
<datalist id="analysis-protocols">{PROTOCOLS.map((p) => <option key={p} value={p} />)}</datalist>
|
|
</FieldGroup>
|
|
{ported && (
|
|
<>
|
|
<FieldGroup label={t("analysis.form.dport")} hint={t("analysis.form.dportHint")}>
|
|
<Input value={form.dport} onChange={(e) => set("dport", e.target.value)} inputMode="numeric" autoComplete="off" placeholder="443" />
|
|
</FieldGroup>
|
|
<FieldGroup label={t("analysis.form.sport")} hint={t("analysis.form.sportHint")}>
|
|
<Input value={form.sport} onChange={(e) => set("sport", e.target.value)} inputMode="numeric" autoComplete="off" />
|
|
</FieldGroup>
|
|
</>
|
|
)}
|
|
{icmp && (
|
|
<FieldGroup label={t("analysis.form.icmptype")} hint={t("analysis.form.icmptypeHint")}>
|
|
<Input value={form.icmptype} onChange={(e) => set("icmptype", e.target.value)} autoComplete="off" placeholder="echoreq" />
|
|
</FieldGroup>
|
|
)}
|
|
<FieldGroup label={t("analysis.form.iface")} hint={t("analysis.form.ifaceHint")}>
|
|
<Select value={form.iface} onChange={(e) => set("iface", e.target.value)}>
|
|
<option value="auto">{t("analysis.form.ifaceAuto")}</option>
|
|
{ifaceChoices(form.iface).map((o) => <option key={o.name} value={o.name}>{o.label || o.name}</option>)}
|
|
</Select>
|
|
</FieldGroup>
|
|
</div>
|
|
|
|
{form.scope === "hosts" && (
|
|
<fieldset className="min-w-0">
|
|
<legend className="mb-1 text-sm font-medium text-slate-700">{t("analysis.form.hosts")}</legend>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
|
{enabledHosts.map((h) => (
|
|
<label key={h.id} className="inline-flex items-center gap-2 text-sm text-slate-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.hostIds.includes(h.id)}
|
|
onChange={(e) => set("hostIds", e.target.checked ? [...form.hostIds, h.id] : form.hostIds.filter((id) => id !== h.id))}
|
|
/>
|
|
{h.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
|
|
<label className="inline-flex min-h-[2.5rem] items-center gap-2 text-sm font-medium text-slate-700">
|
|
<input type="checkbox" checked={form.egress} onChange={(e) => set("egress", e.target.checked)} />
|
|
{t("analysis.form.egress")}
|
|
<InfoTip text={t("analysis.form.egressHint")} size={13} />
|
|
</label>
|
|
{form.egress && (
|
|
<div className="w-full sm:w-56">
|
|
<Label htmlFor="analysis-egress-iface">{t("analysis.form.egressIface")}</Label>
|
|
<Select id="analysis-egress-iface" aria-label={t("analysis.form.egressIface")} value={form.egressIface} onChange={(e) => set("egressIface", e.target.value)}>
|
|
<option value="auto">{t("analysis.form.ifaceAuto")}</option>
|
|
{ifaceChoices(form.egressIface).map((o) => <option key={o.name} value={o.name}>{o.label || o.name}</option>)}
|
|
</Select>
|
|
</div>
|
|
)}
|
|
<div className="ml-auto flex flex-wrap gap-2">
|
|
<Button type="button" variant="outline" onClick={() => submit(undefined, true)} disabled={running} title={t("analysis.form.refreshHint")}>
|
|
{t("analysis.form.refresh")}
|
|
</Button>
|
|
<Button type="submit" loading={running}>
|
|
<Search size={16} aria-hidden="true" />
|
|
{t("analysis.form.run")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{formError && <p role="alert" className="text-sm text-red-600">{formError}</p>}
|
|
</form>
|
|
</Card>
|
|
|
|
{error && <p role="alert" className="mb-4 text-sm text-red-600">{error}</p>}
|
|
{running && results === null && <Loading>{t("analysis.form.run")}…</Loading>}
|
|
{results === null && !running && !error && <EmptyState icon={Network} title={t("analysis.empty.title")} description={t("analysis.empty.description")} />}
|
|
{results && results.length === 0 && <EmptyState icon={Network} title={t("analysis.noHosts.title")} description={t("analysis.noHosts.description")} />}
|
|
{results && results.length > 0 && (
|
|
<div className="grid gap-4 lg:grid-cols-2" aria-busy={running} role="region" aria-label={t("analysis.title")}>
|
|
{results.map((result) => <RuleAnalysisCard key={result.host_id} result={result} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|