mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 08:49:30 +00:00
224 lines
7.2 KiB
TypeScript
224 lines
7.2 KiB
TypeScript
import { ArrowRight, Terminal } from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import Chip from "~/components/chip";
|
|
import TableList from "~/components/table-list";
|
|
import type { AclRule, Policy, SshRule } from "~/utils/acl-policy";
|
|
|
|
import AclRuleDialog from "../dialogs/acl-rule";
|
|
import HostDialog from "../dialogs/host";
|
|
import SshRuleDialog from "../dialogs/ssh-rule";
|
|
import { Empty, RowActions, Section } from "./editor-section";
|
|
|
|
interface RulesEditorProps {
|
|
policy: Policy;
|
|
onChange: (policy: Policy) => void;
|
|
isDisabled: boolean;
|
|
sources: string[];
|
|
destinations: string[];
|
|
}
|
|
|
|
type Editing =
|
|
| { kind: "acl"; index: number | null }
|
|
| { kind: "ssh"; index: number | null }
|
|
| { kind: "host"; name: string | null }
|
|
| null;
|
|
|
|
export default function RulesEditor({
|
|
policy,
|
|
onChange,
|
|
isDisabled,
|
|
sources,
|
|
destinations,
|
|
}: RulesEditorProps) {
|
|
const [editing, setEditing] = useState<Editing>(null);
|
|
|
|
const aclRule =
|
|
editing?.kind === "acl" && editing.index !== null ? policy.acls[editing.index] : undefined;
|
|
const sshRule =
|
|
editing?.kind === "ssh" && editing.index !== null ? policy.ssh[editing.index] : undefined;
|
|
const hostName = editing?.kind === "host" ? editing.name : null;
|
|
|
|
function saveAcl(rule: AclRule) {
|
|
const acls = [...policy.acls];
|
|
if (editing?.kind === "acl" && editing.index !== null) {
|
|
acls[editing.index] = rule;
|
|
} else {
|
|
acls.push(rule);
|
|
}
|
|
onChange({ ...policy, acls });
|
|
}
|
|
|
|
function saveSsh(rule: SshRule) {
|
|
const ssh = [...policy.ssh];
|
|
if (editing?.kind === "ssh" && editing.index !== null) {
|
|
ssh[editing.index] = rule;
|
|
} else {
|
|
ssh.push(rule);
|
|
}
|
|
onChange({ ...policy, ssh });
|
|
}
|
|
|
|
function saveHost(name: string, value: string) {
|
|
const hosts = { ...policy.hosts };
|
|
if (hostName !== null && hostName !== name) {
|
|
delete hosts[hostName];
|
|
}
|
|
hosts[name] = value;
|
|
onChange({ ...policy, hosts });
|
|
}
|
|
|
|
const hostEntries = Object.entries(policy.hosts).sort(([a], [b]) => a.localeCompare(b));
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8">
|
|
{editing?.kind === "acl" ? (
|
|
<AclRuleDialog
|
|
destinations={destinations}
|
|
isOpen
|
|
onSave={saveAcl}
|
|
rule={aclRule}
|
|
setIsOpen={(open) => {
|
|
if (!open) setEditing(null);
|
|
}}
|
|
sources={sources}
|
|
/>
|
|
) : null}
|
|
{editing?.kind === "ssh" ? (
|
|
<SshRuleDialog
|
|
destinations={destinations}
|
|
isOpen
|
|
onSave={saveSsh}
|
|
rule={sshRule}
|
|
setIsOpen={(open) => {
|
|
if (!open) setEditing(null);
|
|
}}
|
|
sources={sources}
|
|
/>
|
|
) : null}
|
|
{editing?.kind === "host" ? (
|
|
<HostDialog
|
|
existingNames={Object.keys(policy.hosts)}
|
|
isOpen
|
|
name={hostName ?? undefined}
|
|
onSave={saveHost}
|
|
setIsOpen={(open) => {
|
|
if (!open) setEditing(null);
|
|
}}
|
|
value={hostName ? policy.hosts[hostName] : undefined}
|
|
/>
|
|
) : null}
|
|
|
|
<Section
|
|
description="Rules are evaluated top to bottom. Traffic is denied unless a rule allows it."
|
|
isDisabled={isDisabled}
|
|
onAdd={() => setEditing({ kind: "acl", index: null })}
|
|
title="Access rules"
|
|
>
|
|
{policy.acls.length === 0 ? (
|
|
<Empty text="No access rules are defined yet." />
|
|
) : (
|
|
policy.acls.map((rule, index) => (
|
|
<TableList.Item
|
|
className="flex-col items-stretch gap-2 py-3 md:flex-row md:items-center"
|
|
key={`acl-${index}`}
|
|
>
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
{/* An unknown action is shown verbatim, never relabelled. */}
|
|
<span className="text-xs font-semibold uppercase opacity-60">
|
|
{rule.action === "accept" ? "Allow" : rule.action}
|
|
</span>
|
|
<ChipRow values={rule.src} />
|
|
<ArrowRight className="h-4 w-4 shrink-0 opacity-60" />
|
|
<ChipRow values={rule.dst} />
|
|
{rule.proto ? <Chip className="uppercase" text={rule.proto} /> : null}
|
|
</div>
|
|
<RowActions
|
|
isDisabled={isDisabled}
|
|
onDelete={() =>
|
|
onChange({ ...policy, acls: policy.acls.filter((_, i) => i !== index) })
|
|
}
|
|
onEdit={() => setEditing({ kind: "acl", index })}
|
|
/>
|
|
</TableList.Item>
|
|
))
|
|
)}
|
|
</Section>
|
|
|
|
<Section
|
|
description="Control which nodes can be reached over Tailscale SSH and as which local user."
|
|
isDisabled={isDisabled}
|
|
onAdd={() => setEditing({ kind: "ssh", index: null })}
|
|
title="SSH rules"
|
|
>
|
|
{policy.ssh.length === 0 ? (
|
|
<Empty text="No SSH rules are defined yet." />
|
|
) : (
|
|
policy.ssh.map((rule, index) => (
|
|
<TableList.Item
|
|
className="flex-col items-stretch gap-2 py-3 md:flex-row md:items-center"
|
|
key={`ssh-${index}`}
|
|
>
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<Terminal className="h-4 w-4 shrink-0 opacity-60" />
|
|
<span className="text-xs font-semibold uppercase opacity-60">{rule.action}</span>
|
|
<ChipRow values={rule.src} />
|
|
<ArrowRight className="h-4 w-4 shrink-0 opacity-60" />
|
|
<ChipRow values={rule.dst} />
|
|
<span className="text-xs opacity-60">as</span>
|
|
<ChipRow values={rule.users} />
|
|
</div>
|
|
<RowActions
|
|
isDisabled={isDisabled}
|
|
onDelete={() =>
|
|
onChange({ ...policy, ssh: policy.ssh.filter((_, i) => i !== index) })
|
|
}
|
|
onEdit={() => setEditing({ kind: "ssh", index })}
|
|
/>
|
|
</TableList.Item>
|
|
))
|
|
)}
|
|
</Section>
|
|
|
|
<Section
|
|
description="Named IP addresses and CIDR ranges that can be referenced from rules."
|
|
isDisabled={isDisabled}
|
|
onAdd={() => setEditing({ kind: "host", name: null })}
|
|
title="Hosts"
|
|
>
|
|
{hostEntries.length === 0 ? (
|
|
<Empty text="No hosts are defined yet." />
|
|
) : (
|
|
hostEntries.map(([name, value]) => (
|
|
<TableList.Item key={name}>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="font-mono text-sm">{name}</span>
|
|
<span className="font-mono text-sm opacity-60">{value}</span>
|
|
</div>
|
|
<RowActions
|
|
isDisabled={isDisabled}
|
|
onDelete={() => {
|
|
const hosts = { ...policy.hosts };
|
|
delete hosts[name];
|
|
onChange({ ...policy, hosts });
|
|
}}
|
|
onEdit={() => setEditing({ kind: "host", name })}
|
|
/>
|
|
</TableList.Item>
|
|
))
|
|
)}
|
|
</Section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChipRow({ values }: { values: string[] }) {
|
|
return (
|
|
<span className="flex flex-wrap items-center gap-1">
|
|
{values.map((value) => (
|
|
<Chip className="font-mono" key={value} text={value} />
|
|
))}
|
|
</span>
|
|
);
|
|
}
|