Files
headplane/app/routes/acls/components/rules-editor.tsx
T

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>
);
}