Files
OPNexus/frontend/app/rules/page.tsx
T
Julian Boldt 20fd575420
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.12.0
Netzwerk-Verwaltung und Übernahme von Hand angelegter Regeln. Alles gegen die
OPNsense-Core-Quellen geprüft und live auf drei Test-Firewalls (26.7.1 und
26.7.6) getestet, siehe Handbuch „Netzwerk, DNS und DHCP“:

- **Neuer Bereich „Netzwerk“:** Inventar je Firewall (Schnittstellen mit
  Adressen und CARP-Zustand, VLANs, virtuelle IPs, Gateways, Routen, DNS-Einträge,
  DHCP) und Änderungen daran: anlegen, bearbeiten, löschen für VLANs, virtuelle
  IPs, Gateways, statische Routen, Schnittstellen-Zuweisung samt IP-Konfiguration,
  Unbound-DNS-Einträge, dnsmasq-Hosts sowie Kea-Subnetze und -Reservierungen.
- **Abgesichert:** Jede Änderung läuft über eine Vorschau mit Diff und einem Token,
  der den Firewall-Stand erfasst. Vor dem Schreiben wird ein Konfig-Backup
  gespeichert (ohne Backup keine Änderung), danach angewendet und zurückgelesen.
  OPNexus erkennt, über welche Schnittstelle es die Firewall erreicht; Änderungen,
  die diese Verbindung kappen können (Verwaltungs-Schnittstelle, Standard-Gateway,
  Standardroute, Route über die Firewall-Adresse), brauchen eine ausdrückliche
  Bestätigung mit Eingabe des Firewall-Namens. Es gibt bewusst **keinen**
  automatischen Rollback: OPNexus kann eine Änderung nicht zurücknehmen, wenn sie
  seine eigene Verbindung trennt. Das Backup vor der Änderung ist der Rückweg.
- **Schnittstellen-Löschen:** OPNsense entfernt dabei selbst alle Regeln, die die
  Schnittstelle nutzen. Die Vorschau listet sie, die Bestätigung ist Pflicht.
  Bereits auf der Firewall vorgemerkte Änderungen blockieren eine neue Änderung.
- **IP-Konfiguration per API gibt es erst ab OPNsense 26.7.6.** Auf 26.7.1 ist die
  Zuweisungsliste nur lesbar; OPNexus erkennt das an den Daten und sperrt die
  Änderung mit Begründung.
- **Von der Firewall übernehmen:** Regeln, die direkt in OPNsense angelegt wurden,
  lassen sich in OPNexus übernehmen, bearbeiten und ausrollen. Die Übernahme schreibt
  nichts auf die Firewall. Regeln, die das Modell verfälschen würde (invertierte
  Quelle/Ziel, mehrere Schnittstellen, keine Quick-Regel, …), werden mit Grund
  abgelehnt. Neu: Regeln haben ein Protokollieren-Flag (`log`, Migration 0003),
  damit es beim Bearbeiten nicht still verloren geht; nicht modellierte Felder
  (Gateway, Zustandsoptionen, …) bleiben auf der Firewall unverändert.
- **Geheimnisse:** Das CARP-Passwort (liefert OPNsense im Klartext) und der
  Kea-DDNS-Schlüssel werden nie zurückgegeben, gespeichert oder auditiert.
- **Host-Adresse änderbar:** Hosts › Bearbeiten hat jetzt das Feld „Adresse“. Nach
  einer Änderung der Verwaltungs-IP muss der Host nicht mehr gelöscht und neu
  angelegt werden; Verlauf und Zuordnungen bleiben erhalten. Meldet eine Änderung
  „angewendet, aber nicht prüfbar“, weist die Oberfläche darauf hin.
- **Fehler aus den Live-Tests, die Mocks nicht gezeigt haben:** Jede Änderung einer
  virtuellen IP muss die Adresse (`network`) wiederholen, sonst leert OPNsense sie
  bzw. lehnt ab; die Prüfung nach dem Anwenden vergleicht jetzt den vollständigen
  Soll-Zustand statt nur der gesendeten Felder. Dazu: Gateway-Priorität als Zahl,
  Routen mit `enabled` statt `disabled`, Beschreibungsmuster der Schnittstellen,
  gesperrte Schnittstellen, Pflicht-Backup vor jeder Änderung.
- **Hinweis zu den Versionen:** Die IP-Konfiguration von Schnittstellen per API gibt
  es erst ab OPNsense 26.7.6. Mit 26.7.5 (die Prod-Firewalls) sind VLANs, virtuelle
  IPs, Gateways, Routen, DNS und DHCP verwaltbar, Schnittstellen nur lesbar.
2026-10-09 21:23:04 +02:00

808 lines
36 KiB
TypeScript

"use client";
import { Tooltip, InfoTip } from "@/components/ui/Tooltip";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { WorkflowBadge, workflowStates } from "@/components/WorkflowBadge";
import { RuleReview } from "@/components/RuleReview";
import { Badge, BadgeTone } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { FieldGroup, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { Tabs } from "@/components/ui/Tabs";
import { useToast } from "@/components/ui/Toast";
import { LiveRulesDrift } from "@/components/LiveRulesDrift";
import { AdoptRulesModal } from "@/components/AdoptRulesModal";
import { useReadOnly } from "@/lib/useReadOnly";
import {
api,
AliasOption,
ApiError,
DeploymentResult,
DeviceGroup,
Host,
InterfaceOption,
Rule,
RuleAction,
RuleCreate,
RuleDirection,
} from "@/lib/api";
import {
AlertTriangle,
CheckCircle2,
Clock,
Download,
Plus,
Pencil,
History,
Copy,
Rocket,
RotateCcw,
Shield,
ShieldCheck,
Trash2,
XCircle,
} from "lucide-react";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useLanguage } from "@/components/LanguageProvider";
import { Loading } from "@/components/ui/Loading";
interface ComboOption {
value: string;
label: string;
}
// OPNsense's port fields only ever accept a raw number/range or an alias
// name — there's no live API exposing well-known service names the way
// interfaces/protocols/aliases are exposed, so suggesting these by name
// while always inserting the numeric port keeps every suggestion valid
// regardless of what the target firewall's own /etc/services contains.
const WELL_KNOWN_PORTS: ComboOption[] = [
{ value: "80", label: "http" },
{ value: "443", label: "https" },
{ value: "22", label: "ssh" },
{ value: "21", label: "ftp" },
{ value: "20", label: "ftp-data" },
{ value: "23", label: "telnet" },
{ value: "25", label: "smtp" },
{ value: "465", label: "smtps" },
{ value: "587", label: "submission (smtp)" },
{ value: "110", label: "pop3" },
{ value: "995", label: "pop3s" },
{ value: "143", label: "imap" },
{ value: "993", label: "imaps" },
{ value: "53", label: "dns" },
{ value: "123", label: "ntp" },
{ value: "161", label: "snmp" },
{ value: "162", label: "snmptrap" },
{ value: "389", label: "ldap" },
{ value: "636", label: "ldaps" },
{ value: "3306", label: "mysql" },
{ value: "5432", label: "postgresql" },
{ value: "3389", label: "rdp" },
{ value: "445", label: "smb / microsoft-ds" },
{ value: "139", label: "netbios-ssn" },
{ value: "5060", label: "sip" },
{ value: "514", label: "syslog" },
{ value: "69", label: "tftp" },
{ value: "8080", label: "http-alt" },
{ value: "8443", label: "https-alt" },
{ value: "2049", label: "nfs" },
{ value: "6379", label: "redis" },
{ value: "27017", label: "mongodb" },
{ value: "9200", label: "elasticsearch" },
];
const emptyForm: RuleCreate = {
description: "",
interface: "lan",
direction: "in",
ipprotocol: "inet",
protocol: "tcp",
action: "pass",
source_net: "any",
source_port: null,
destination_net: "any",
destination_port: null,
enabled: true,
log: false,
target_host_ids: [],
target_group_ids: [],
};
export default function RulesPage() {
const { t } = useLanguage();
return <Suspense fallback={<Loading>{t("rules.loading")}</Loading>}><RulesContent /></Suspense>;
}
function RulesContent() {
const readOnly = useReadOnly();
const toast = useToast();
const { t, errorMessage } = useLanguage();
const router = useRouter();
const params = useSearchParams();
const groupFilter = params.get("group") ?? "";
const ruleFilter = params.get("rule") ?? "";
const [stateFilter, setStateFilter] = useState("");
const autoOpened = useRef("");
const [rules, setRules] = useState<Rule[] | null>(null);
const [hosts, setHosts] = useState<Host[]>([]);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [showAdopt, setShowAdopt] = useState(false);
const [form, setForm] = useState<RuleCreate>(emptyForm);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [editing, setEditing] = useState<Rule | null>(null);
const [groups, setGroups] = useState<DeviceGroup[]>([]);
const [review, setReview] = useState<{ rule: Rule; mode: "preview" | "history" } | null>(null);
const [search, setSearch] = useState("");
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const [activeTab, setActiveTab] = useState<"managed" | "live">("managed");
const [interfaceOptions, setInterfaceOptions] = useState<InterfaceOption[]>([]);
const [interfacesError, setInterfacesError] = useState<string | null>(null);
const [manualInterface, setManualInterface] = useState(false);
const [aliasOptions, setAliasOptions] = useState<AliasOption[]>([]);
const [protocolOptions, setProtocolOptions] = useState<InterfaceOption[]>([]);
const protocolFallback: InterfaceOption[] = [
{ name: "any", label: "any" },
{ name: "tcp", label: "TCP" },
{ name: "udp", label: "UDP" },
{ name: "icmp", label: "ICMP" },
];
useEffect(() => {
setActiveTab(params.get("tab") === "live" ? "live" : "managed");
}, [params]);
function setGroupFilter(id: string) {
setSearch(""); setStateFilter("");
const query = new URLSearchParams();
if (id) query.set("group", id);
router.push(`/rules${query.size ? `?${query.toString()}` : ""}`);
}
useEffect(() => {
const key = params.toString();
if (rules !== null && params.get("new") === "1" && key !== autoOpened.current && (!groupFilter || groups.some(g => g.id === groupFilter))) {
autoOpened.current = key;
openEditor(null);
const query = new URLSearchParams(params.toString());
query.delete("new");
router.replace(`/rules${query.size ? `?${query.toString()}` : ""}`, { scroll: false });
}
}, [params, rules, groups, groupFilter]);
const hostsById = useMemo(() => new Map(hosts.map((h) => [h.id, h])), [hosts]);
async function load() {
try {
const [ruleList, hostList, groupList] = await Promise.all([api.listRules(), api.listHosts(), api.listGroups()]);
setGroups(groupList);
setRules(ruleList);
setHosts(hostList);
setError(null);
} catch (e) {
setError(errorMessage(e, "rules.loadFailed"));
}
}
useEffect(() => {
load();
}, []);
// Interface dropdown options, merged live across every enabled host so
// rules that target more than one host still get a sensible list (in an
// HA pair the interface names are normally identical anyway). Keyed off
// the host-id set rather than re-run on every load(), since load() also
// polls every 5s while a deployment is pending and this hits each
// firewall's own API.
const enabledHostIdsKey = useMemo(
() => hosts.filter((h) => h.enabled).map((h) => h.id).sort().join(","),
[hosts]
);
useEffect(() => {
if (!enabledHostIdsKey) {
setInterfaceOptions([]);
setInterfacesError(null);
setAliasOptions([]);
setProtocolOptions([]);
return;
}
let cancelled = false;
(async () => {
const ids = enabledHostIdsKey.split(",");
const [interfaceResults, aliasResults, protocolResults] = await Promise.all([
Promise.allSettled(ids.map((id) => api.getHostInterfaces(id))),
Promise.allSettled(ids.map((id) => api.getHostAliases(id))),
Promise.allSettled(ids.map((id) => api.getHostProtocols(id))),
]);
if (cancelled) return;
const merged = new Map<string, string>();
let failures = 0;
for (const r of interfaceResults) {
if (r.status === "fulfilled") {
for (const opt of r.value) {
const existing = merged.get(opt.name);
if (!existing) merged.set(opt.name, opt.label);
else if (!existing.split(" / ").includes(opt.label)) merged.set(opt.name, `${existing} / ${opt.label}`);
}
} else {
failures++;
}
}
setInterfaceOptions(
Array.from(merged, ([name, label]) => ({ name, label })).sort((a, b) => a.label.localeCompare(b.label))
);
setInterfacesError(
failures > 0 ? t("rules.interfacesUnavailable", { failed: failures, total: ids.length }) : null
);
// Aliases are config-synced across the fleet in practice, so unlike
// interfaces this doesn't try to detect/merge conflicting content —
// just offers every distinct name once, first host wins. The deploy
// endpoint still checks live content-consistency before any push.
const aliasMerged = new Map<string, AliasOption>();
for (const r of aliasResults) {
if (r.status === "fulfilled") {
for (const a of r.value) {
if (!aliasMerged.has(a.name)) aliasMerged.set(a.name, a);
}
}
}
setAliasOptions(Array.from(aliasMerged.values()).sort((a, b) => a.name.localeCompare(b.name)));
// Protocol names are IANA constants baked into OPNsense itself, not
// host config, so — unlike interfaces — no cross-host conflict is
// possible here; first host to answer wins.
const protocolMerged = new Map<string, string>();
for (const r of protocolResults) {
if (r.status === "fulfilled") {
for (const p of r.value) if (!protocolMerged.has(p.name)) protocolMerged.set(p.name, p.label);
}
}
setProtocolOptions(
Array.from(protocolMerged, ([name, label]) => ({ name, label })).sort((a, b) =>
a.name === "any" ? -1 : b.name === "any" ? 1 : a.label.localeCompare(b.label)
)
);
})();
return () => {
cancelled = true;
};
}, [enabledHostIdsKey, t]);
// Live countdown display, ticking independently of the network poll below.
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
// One stable interval for the page's lifetime; only actually re-fetches
// while some deployment is still pending confirmation, via a ref so this
// doesn't need to be torn down/recreated as `rules` changes.
const rulesRef = useRef(rules);
rulesRef.current = rules;
useEffect(() => {
const t = setInterval(() => {
const hasPending = rulesRef.current?.some((r) => r.deployments.some((d) => d.status === "pending"));
if (hasPending) load();
}, 5000);
return () => clearInterval(t);
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setFormError(null);
try {
if (editing) await api.updateRule(editing.id, form, editing.updated_at);
else await api.createRule(form);
setShowModal(false);
setForm(emptyForm);
await load();
} catch (err) {
setFormError(errorMessage(err, "rules.createFailed"));
} finally {
setSaving(false);
}
}
function openEditor(rule: Rule | null, copy = false, restored?: RuleCreate) {
setEditing(copy ? null : rule);
const candidate = restored ?? (rule ? { ...rule, description: copy ? `${rule.description.slice(0, 247)}${t("rules.copySuffix")}` : rule.description } : { ...emptyForm, target_group_ids: groupFilter && groups.some(g => g.id === groupFilter) ? [groupFilter] : [] });
const available = restored ? {
...candidate,
target_host_ids: candidate.target_host_ids.filter(id => hosts.some(h => h.id === id)),
target_group_ids: candidate.target_group_ids.filter(id => groups.some(g => g.id === id)),
} : candidate;
setForm(available);
setFormError(available.target_host_ids.length !== candidate.target_host_ids.length || available.target_group_ids.length !== candidate.target_group_ids.length
? t("rules.staleTargets")
: null);
setManualInterface(false);
setReview(null);
setShowModal(true);
}
async function handleConfirm(id: string) {
setConfirmingId(id);
try {
await api.confirmRule(id);
await load();
toast.show(t("rules.confirmed"), "success");
} catch (err) {
setError(errorMessage(err, "rules.confirmFailed"));
} finally {
setConfirmingId(null);
}
}
async function handleDelete(id: string) {
if (!confirm(t("rules.deleteConfirm"))) return;
const description = rules?.find((r) => r.id === id)?.description;
try { await api.deleteRule(id); await load(); toast.show(description ? t("rules.deletedNamed", { name: description }) : t("rules.deleted"), "success"); }
catch (e) { setError(errorMessage(e, "common.deleteFailed")); }
}
function toggleTarget(id: string) {
setForm((f) => ({
...f,
target_host_ids: f.target_host_ids.includes(id)
? f.target_host_ids.filter((x) => x !== id)
: [...f.target_host_ids, id],
}));
}
function secondsLeft(deadline: string): number {
return Math.max(0, Math.round((new Date(deadline).getTime() - now) / 1000));
}
const interfaceSelectOptions = interfaceOptions.some((opt) => opt.name === form.interface)
? interfaceOptions
: form.interface
? [{ name: form.interface, label: form.interface }, ...interfaceOptions]
: interfaceOptions;
// A "/" in the merged label means hosts disagreed on what this identifier
// means — e.g. "opt6" is "Guest" on one firewall and "Dummy01" on
// another. The backend rejects the push in that case; this just
// surfaces it before the user gets that far.
const selectedInterfaceLabel = interfaceOptions.find((opt) => opt.name === form.interface)?.label;
const interfaceMismatch = selectedInterfaceLabel?.includes(" / ") ?? false;
const protocolBase = protocolOptions.length > 0 ? protocolOptions : protocolFallback;
const protocolSelectOptions = protocolBase.some((opt) => opt.name === form.protocol)
? protocolBase
: [{ name: form.protocol, label: form.protocol }, ...protocolBase];
const portAliasOptions = aliasOptions.filter((a) => a.type === "port");
const netComboOptions: ComboOption[] = aliasOptions.map((a) => ({
value: a.name,
label: a.description ? `${a.description} (${a.type})` : a.type,
}));
const portComboOptions: ComboOption[] = [
...WELL_KNOWN_PORTS,
...portAliasOptions.map((a) => ({ value: a.name, label: a.description || a.type })),
];
const selectedGroup = groups.find(g => g.id === groupFilter);
const groupRules = (rules ?? []).filter(rule => !groupFilter || rule.target_group_ids.includes(groupFilter));
const visibleRules = groupRules.filter(rule => (!ruleFilter || rule.id === ruleFilter) && (!stateFilter || rule.workflow_state === stateFilter) &&
[rule.description, rule.source_net, rule.source_port, rule.destination_net, rule.destination_port, rule.interface, rule.protocol].join(" ").toLowerCase().includes(search.toLowerCase()));
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-slate-900 flex items-center gap-2">{selectedGroup ? t("rules.groupTitle", { name: selectedGroup.name }) : t("rules.title")}<InfoTip text={t("rules.subtitle")} size={18} /></h1>
{activeTab === "managed" && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setShowAdopt(true)} disabled={readOnly}>
<Download size={16} />
{t("rules.adopt.button")}
</Button>
<Button onClick={() => openEditor(null)} disabled={readOnly}>
<Plus size={16} />
{t("rules.new")}
</Button>
</div>
)}
</div>
<Tabs
className="mb-6"
items={[
{ key: "managed", label: t("rules.managed") },
{ key: "live", label: t("rules.live") },
]}
active={activeTab}
onChange={(key) => setActiveTab(key as "managed" | "live")}
/>
{activeTab === "live" ? (
<LiveRulesDrift />
) : (
<>
{error && <div className="mb-4"><p role="alert" className="text-sm text-red-600">{error}</p><Button variant="ghost" onClick={load}>{t("rules.retry")}</Button></div>}
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<Input aria-label={t("rules.searchLabel")} placeholder={t("rules.searchPlaceholder")} value={search} onChange={e => setSearch(e.target.value)} />
<Select aria-label={t("rules.groupFilter")} value={groupFilter} onChange={e => setGroupFilter(e.target.value)}><option value="">{t("rules.allGroups")}</option>{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}</Select>
<Select aria-label={t("rules.stateFilter")} value={stateFilter} onChange={e => setStateFilter(e.target.value)}><option value="">{t("rules.allStates")}</option>{workflowStates.map((value) => <option key={value} value={value}>{t(`workflow.${value}`)}</option>)}</Select>
</div>
{selectedGroup && <p className="mb-3 text-sm text-slate-500">{t("rules.groupSummary", { members: selectedGroup.host_ids.length, rules: groupRules.length })}<Link href="/groups" className="text-sky-700 underline">{t("rules.manageMembers")}</Link></p>}
{ruleFilter && <Button variant="ghost" onClick={() => setGroupFilter(groupFilter)}>{selectedGroup ? t("rules.showAllInGroup") : t("rules.showAll")}</Button>}
{rules && <div className="mb-4 flex flex-wrap gap-4 text-sm text-slate-600"><span>{t("rules.countDraft", { count: groupRules.filter(r => r.workflow_state === "draft").length })}</span><span>{t("rules.countReview", { count: groupRules.filter(r => ["attention", "cleanup", "pending"].includes(r.workflow_state)).length })}</span><span>{t("rules.countSynced", { count: groupRules.filter(r => r.workflow_state === "synced").length })}</span></div>}
<div className="flex flex-col gap-4">
{rules === null && !error && <Card className="p-6"><Loading>{t("rules.loading")}</Loading></Card>}
{rules !== null && visibleRules.length === 0 && (
<EmptyState
icon={Shield}
title={groupFilter && !selectedGroup ? t("rules.groupGone") : groupRules.length === 0 ? (selectedGroup ? t("rules.groupEmpty") : t("rules.none")) : t("rules.noMatch")}
action={groupRules.length === 0 && (!groupFilter || selectedGroup)
? <Button disabled={readOnly} onClick={() => openEditor(null)}>{selectedGroup ? t("rules.createFirstForGroup") : t("rules.createFirst")}</Button>
: <Button variant="ghost" onClick={() => { setSearch(""); setStateFilter(""); setGroupFilter(selectedGroup ? groupFilter : ""); }}>{t("rules.resetSelection")}</Button>}
/>
)}
<fieldset disabled={readOnly} className="contents">
{visibleRules.map((rule) => {
const hasPending = rule.deployments.some((d) => d.status === "pending");
return (
<Card key={rule.id} className="p-5">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-medium text-slate-900">{rule.description}</h2>
<ActionBadge action={rule.action} />
<WorkflowBadge state={rule.workflow_state} />
</div>
<p className="mt-1 text-sm text-slate-500">
{rule.interface} · {rule.direction === "in" ? t("rules.dirIn") : t("rules.dirOut")} · {rule.protocol} ·{" "}
{rule.source_net}
{rule.source_port ? `:${rule.source_port}` : ""} → {rule.destination_net}
{rule.destination_port ? `:${rule.destination_port}` : ""}
</p>
<p className="mt-2 text-xs text-slate-500">{t("rules.targets", { active: rule.workflow_counts.active_targets, total: rule.workflow_counts.targets })}{rule.workflow_counts.retained > 0 ? t("rules.retainedTargets", { count: rule.workflow_counts.retained }) : ""}{!rule.enabled ? t("rules.disabledSuffix") : ""}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{rule.target_group_ids.map(id => <span key={id} className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs text-blue-800">{t("rules.groupTag", { name: groups.find(g => g.id === id)?.name ?? id.slice(0, 8) })}</span>)}
{rule.target_host_ids.map((id) => (
<span key={id} className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-600">
{hostsById.get(id)?.name ?? id.slice(0, 8)}
</span>
))}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="outline" onClick={() => openEditor(rule)} disabled={rule.deployments.some(d => ["pending", "revert_failed"].includes(d.status))}><Pencil size={14} />{t("rules.edit")}</Button>
<Button onClick={() => setReview({ rule, mode: "preview" })}><Rocket size={14} />{t("rules.reviewPush")}</Button>
<Tooltip content={t("rules.historyTitle")}><Button variant="ghost" onClick={() => setReview({ rule, mode: "history" })} aria-label={t("rules.historyTitle")}><History size={14} /></Button></Tooltip>
<Tooltip content={t("rules.duplicateTitle")}><Button variant="ghost" onClick={() => openEditor(rule, true)} aria-label={t("rules.duplicateTitle")}><Copy size={14} /></Button></Tooltip>
<Button variant="dangerSubtle" onClick={() => handleDelete(rule.id)}>
<Trash2 size={14} />
{t("rules.delete")}
</Button>
</div>
</div>
{rule.deployments.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-slate-100 pt-3">
<p className="text-xs font-medium uppercase tracking-wide text-slate-400">
{t("rules.deployResult")}
</p>
<ul className="flex flex-wrap gap-x-5 gap-y-1.5">
{rule.deployments.map((d) => (
<DeploymentRow key={d.host_id} deployment={d} secondsLeft={secondsLeft} />
))}
</ul>
{hasPending && (
<Button className="ml-auto" variant="secondary" onClick={() => handleConfirm(rule.id)} disabled={confirmingId === rule.id}>
<ShieldCheck size={14} />
{confirmingId === rule.id ? t("rules.confirming") : t("rules.confirm")}
</Button>
)}
</div>
)}
</Card>
);
})}
</fieldset>
</div>
</>
)}
<fieldset disabled={readOnly} className="contents">
{showAdopt && <AdoptRulesModal hosts={hosts} onClose={() => setShowAdopt(false)} onAdopted={load} />}
{review && <RuleReview rule={review.rule} mode={review.mode} onClose={() => setReview(null)} onDeployed={() => { setReview(null); load(); }} onChanged={load} onRestore={snapshot => openEditor(review.rule, false, snapshot)} />}
{showModal && (
<Modal title={editing ? t("rules.editDraft") : t("rules.new")} onClose={() => setShowModal(false)}>
<form onSubmit={handleCreate} className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
<FieldGroup label={t("common.description")}>
<Input
required
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Allow SSH from management"
/>
</FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label="Interface">
{manualInterface || interfaceOptions.length === 0 ? (
<Input
required
value={form.interface}
onChange={(e) => setForm({ ...form, interface: e.target.value })}
placeholder="lan"
/>
) : (
<Select
required
value={form.interface}
onChange={(e) => setForm({ ...form, interface: e.target.value })}
>
{interfaceSelectOptions.map((opt) => (
<option key={opt.name} value={opt.name}>
{opt.label} ({opt.name})
</option>
))}
</Select>
)}
{interfaceOptions.length > 0 && (
<button
type="button"
className="mt-1 text-xs text-sky-700 underline"
onClick={() => setManualInterface((m) => !m)}
>
{manualInterface ? t("rules.pickFromList") : t("rules.enterManually")}
</button>
)}
{interfaceMismatch && (
<p className="mt-1 text-xs text-red-600">
{t("rules.interfaceMismatch", { label: selectedInterfaceLabel ?? "" })}
</p>
)}
{interfacesError && <p className="mt-1 text-xs text-flame-600">{interfacesError}</p>}
</FieldGroup>
<FieldGroup label={t("fields.direction")}>
<Select
value={form.direction}
onChange={(e) => setForm({ ...form, direction: e.target.value as RuleDirection })}
>
<option value="in">{t("rules.dirIn")}</option>
<option value="out">{t("rules.dirOut")}</option>
</Select>
</FieldGroup>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label={t("nat.protocol")}>
<Select
value={form.protocol}
onChange={(e) => setForm({ ...form, protocol: e.target.value })}
>
{protocolSelectOptions.map((opt) => (
<option key={opt.name} value={opt.name}>
{opt.label}
</option>
))}
</Select>
</FieldGroup>
<FieldGroup label={t("fields.action")}>
<Select
value={form.action}
onChange={(e) => setForm({ ...form, action: e.target.value as RuleAction })}
>
<option value="pass">{t("rules.actionPass")}</option>
<option value="block">{t("rules.actionBlock")}</option>
<option value="reject">{t("rules.actionReject")}</option>
</Select>
</FieldGroup>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label="Source">
<ComboInput
value={form.source_net}
onChange={(v) => setForm({ ...form, source_net: v })}
options={netComboOptions}
placeholder={t("rules.netPlaceholder")}
/>
</FieldGroup>
<FieldGroup label={t("nat.sourcePortOptional")}>
<ComboInput
value={form.source_port ?? ""}
onChange={(v) => setForm({ ...form, source_port: v || null })}
options={portComboOptions}
/>
</FieldGroup>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label="Destination">
<ComboInput
value={form.destination_net}
onChange={(v) => setForm({ ...form, destination_net: v })}
options={netComboOptions}
placeholder={t("rules.netPlaceholder")}
/>
</FieldGroup>
<FieldGroup label={t("nat.destinationPortOptional")}>
<ComboInput
value={form.destination_port ?? ""}
onChange={(v) => setForm({ ...form, destination_port: v || null })}
options={portComboOptions}
/>
</FieldGroup>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FieldGroup label={t("nat.ipVersion")}><Select value={form.ipprotocol} onChange={e => setForm({ ...form, ipprotocol: e.target.value })}><option value="inet">IPv4</option><option value="inet6">IPv6</option></Select></FieldGroup>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.enabled} onChange={e => setForm({ ...form, enabled: e.target.checked })} />{t("rules.active")}</label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={!!form.log} onChange={e => setForm({ ...form, log: e.target.checked })} />{t("rules.logPackets")}</label>
</div>
</div>
<FieldGroup label={t("rules.deviceGroups")}>
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
{groups.length === 0 && <p className="text-sm text-slate-500">{t("rules.createGroupsHint")}</p>}
{groups.map(group => <label key={group.id} className="flex items-center gap-2 text-sm"><input type="checkbox" checked={form.target_group_ids.includes(group.id)} onChange={() => setForm(f => ({ ...f, target_group_ids: f.target_group_ids.includes(group.id) ? f.target_group_ids.filter(id => id !== group.id) : [...f.target_group_ids, group.id] }))} />{t("rules.groupHosts", { name: group.name, count: group.host_ids.length })}</label>)}
</div>
</FieldGroup>
<FieldGroup label={t("rules.additionalTargets")}>
<div className="space-y-1.5 rounded-lg border border-slate-200 p-3">
{hosts.length === 0 && <p className="text-sm text-slate-400">{t("rules.noHosts")}</p>}
{hosts.map((host) => {
const coveringGroups = groups.filter(
(g) => form.target_group_ids.includes(g.id) && g.host_ids.includes(host.id)
);
return (
<label key={host.id} className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={form.target_host_ids.includes(host.id)}
onChange={() => toggleTarget(host.id)}
/>
{host.name}
{coveringGroups.length > 0 && (
<span className="text-xs text-slate-400">
{t("rules.coveredBy", { groups: coveringGroups.map((g) => g.name).join(", ") })}
</span>
)}
</label>
);
})}
</div>
</FieldGroup>
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={() => setShowModal(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={saving} loading={saving}>
{saving ? t("common.saving") : t("rules.saveDraft")}
</Button>
</div>
</form>
</Modal>
)}
</fieldset>
</div>
);
}
function DeploymentRow({
deployment,
secondsLeft,
}: {
deployment: DeploymentResult;
secondsLeft: (deadline: string) => number;
}) {
const { t, ts } = useLanguage();
const d = deployment;
return (
<li className="flex items-center gap-2 text-sm">
<DeploymentIcon status={d.status} />
<span className="font-medium text-slate-700">{d.host_name}</span>
{d.status === "pending" && d.revert_deadline && (
<span className="text-flame-600">
{t("rules.expiresIn", { seconds: secondsLeft(d.revert_deadline) })}
</span>
)}
{d.status === "reverted" && (
<span className="text-slate-400">{t("rules.revertedNote")}</span>
)}
{d.status === "revert_failed" && (
<span className="text-red-600">{t("rules.revertFailed", { message: ts(d.message ?? "") })}</span>
)}
{(d.status === "error" || d.status === "success") && d.message && (
<span className="text-slate-400">— {ts(d.message ?? "")}</span>
)}
</li>
);
}
function DeploymentIcon({ status }: { status: DeploymentResult["status"] }) {
switch (status) {
case "success":
return <CheckCircle2 size={15} className="text-emerald-500" />;
case "error":
case "revert_failed":
return <AlertTriangle size={15} className="text-red-500" />;
case "pending":
return <Clock size={15} className="text-flame-500" />;
case "reverted":
return <RotateCcw size={15} className="text-slate-400" />;
default:
return <XCircle size={15} className="text-red-500" />;
}
}
function ActionBadge({ action }: { action: RuleAction }) {
const { t } = useLanguage();
const tones: Record<RuleAction, BadgeTone> = { pass: "success", block: "danger", reject: "warning" };
const labels: Record<RuleAction, string> = { pass: t("rules.actionPass"), block: t("rules.actionBlock"), reject: t("rules.actionReject") };
return <Badge tone={tones[action]}>{labels[action]}</Badge>;
}
// Native <datalist> only filters by an option's `value`, so a lookup like
// "https" never surfaces an alias whose *description* says "DC_MGMT net"
// or a well-known port whose name isn't the value we actually want to
// insert (443, not the string "https" — see WELL_KNOWN_PORTS). This
// filters against value AND label and always inserts the option's value.
function ComboInput({
value,
onChange,
options,
placeholder,
required,
}: {
value: string;
onChange: (value: string) => void;
options: ComboOption[];
placeholder?: string;
required?: boolean;
}) {
const [open, setOpen] = useState(false);
const query = value.trim().toLowerCase();
const suggestions = (
query ? options.filter((o) => o.value.toLowerCase().includes(query) || o.label.toLowerCase().includes(query)) : options
).slice(0, 20);
return (
<div className="relative">
<Input
required={required}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
/>
{open && suggestions.length > 0 && (
<ul className="absolute z-10 mt-1 max-h-56 w-full overflow-y-auto rounded-lg border border-slate-200 bg-white py-1 shadow-lg">
{suggestions.map((o) => (
<li key={o.value}>
<button
type="button"
className="block w-full px-3 py-1.5 text-left text-sm hover:bg-slate-50"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
>
<span className="font-medium text-slate-800">{o.value}</span>
{o.label && o.label !== o.value && <span className="ml-1.5 text-slate-400">{o.label}</span>}
</button>
</li>
))}
</ul>
)}
</div>
);
}