feat(ui): structured editor for ACL rules, tags and groups (#608)

This commit is contained in:
albedev
2026-08-28 21:58:06 +02:00
committed by GitHub
parent 30c842ed8d
commit 72ea6aa0b3
28 changed files with 2414 additions and 37 deletions
+141
View File
@@ -0,0 +1,141 @@
import { Plus, X } from "lucide-react";
import { useMemo, useState } from "react";
import Button from "~/components/button";
import Input from "~/components/input";
import TableList from "~/components/table-list";
import cn from "~/utils/cn";
interface TokenListProps {
label: string;
description?: string;
values: string[];
onChange: (values: string[]) => void;
suggestions?: string[];
placeholder?: string;
emptyText: string;
isDisabled?: boolean;
validate?: (value: string) => boolean;
// Rewrites a value before it is added, e.g. appending a default port.
normalize?: (value: string) => string;
}
// A small chip editor shared by the ACL and user dialogs, modelled on the
// machine tag dialog: current values, a field to add one, and suggestions.
export default function TokenList({
label,
description,
values,
onChange,
suggestions,
placeholder,
emptyText,
isDisabled,
validate,
normalize,
}: TokenListProps) {
const [draft, setDraft] = useState("");
const prepare = useMemo(
() => (value: string) => (normalize ? normalize(value.trim()) : value.trim()),
[normalize],
);
// Suggestions are compared in their normalized form, otherwise picking
// `tag:web` after it was added as `tag:web:*` would duplicate it.
const available = useMemo(
() => (suggestions ?? []).filter((suggestion) => !values.includes(prepare(suggestion))),
[suggestions, values, prepare],
);
const draftIsInvalid = useMemo(() => {
const prepared = prepare(draft);
if (prepared.length === 0) return true;
if (values.includes(prepared)) return true;
return validate ? !validate(prepared) : false;
}, [draft, values, validate, prepare]);
function add(value: string) {
const prepared = prepare(value);
if (prepared.length === 0 || values.includes(prepared)) {
return;
}
onChange([...values, prepared]);
setDraft("");
}
return (
<div className="flex flex-col gap-2">
<div>
<p className="text-sm font-medium text-mist-700 dark:text-mist-200">{label}</p>
{description ? (
<p className="text-xs text-mist-500 dark:text-mist-400">{description}</p>
) : null}
</div>
<TableList>
{values.length === 0 ? (
<TableList.Item className="justify-center py-3 text-sm opacity-70">
{emptyText}
</TableList.Item>
) : (
values.map((value) => (
<TableList.Item className="font-mono text-sm" id={value} key={value}>
{value}
<Button
className="rounded-md p-0.5"
disabled={isDisabled}
onClick={() => onChange(values.filter((entry) => entry !== value))}
type="button"
>
<X className="p-1" />
</Button>
</TableList.Item>
))
)}
</TableList>
<div className="flex items-center gap-2">
<Input
aria-label={`Add to ${label}`}
className="w-full"
disabled={isDisabled}
invalid={draft.length > 0 && draftIsInvalid}
label={label}
labelHidden
onChange={setDraft}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (!draftIsInvalid) add(draft);
}
}}
placeholder={placeholder}
value={draft}
/>
<Button
className={cn("rounded-md p-1", draftIsInvalid && "cursor-not-allowed opacity-50")}
disabled={isDisabled || draftIsInvalid}
onClick={() => add(draft)}
type="button"
>
<Plus className="p-1" size={30} />
</Button>
</div>
{available.length > 0 ? (
<div className="flex flex-wrap gap-2">
{available.map((suggestion) => (
<Button
className="px-2 py-1 font-mono text-xs"
disabled={isDisabled}
key={suggestion}
onClick={() => add(suggestion)}
type="button"
variant="ghost"
>
{suggestion}
</Button>
))}
</div>
) : null}
</div>
);
}
+29 -1
View File
@@ -1,8 +1,10 @@
import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
import type { Route } from "./+types/overview";
@@ -16,6 +18,7 @@ import type { Route } from "./+types/overview";
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_policy);
@@ -30,10 +33,35 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
access: auth.can(principal, Capabilities.write_policy),
writable: false,
policy: "",
// Context for the visual editor; both are optional.
users: [] as string[],
tagUsage: [] as { tag: string; nodes: string[] }[],
};
// Try to load the ACL policy from the API.
const { api } = await getRequestApi(request);
try {
const [nodesSnap, usersSnap] = await Promise.all([
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
flags.users = usersSnap.data.map((user) => user.name).sort();
const usage = new Map<string, string[]>();
for (const node of nodesSnap.data) {
for (const tag of node.tags) {
usage.set(tag, [...(usage.get(tag) ?? []), node.givenName || node.name]);
}
}
flags.tagUsage = Array.from(usage.entries())
.map(([tag, nodes]) => ({ tag, nodes }))
.sort((a, b) => a.tag.localeCompare(b.tag));
} catch (error) {
log.warn("api", "Failed to load ACL editor context: %s", String(error));
}
try {
const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null;
@@ -0,0 +1,68 @@
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
import Button from "~/components/button";
import TableList from "~/components/table-list";
// Chrome shared by the rules and the tags/groups editors.
interface SectionProps {
title: string;
description: ReactNode;
isDisabled: boolean;
onAdd: () => void;
children: ReactNode;
}
export function Section({ title, description, isDisabled, onAdd, children }: SectionProps) {
return (
<section>
<div className="mb-3 flex items-end justify-between gap-4">
<div>
<h2 className="text-lg font-medium">{title}</h2>
<p className="max-w-prose text-sm text-mist-600 dark:text-mist-300">{description}</p>
</div>
<Button className="shrink-0" disabled={isDisabled} onClick={onAdd} type="button">
<Plus className="h-4 w-4" />
Add
</Button>
</div>
<TableList>{children}</TableList>
</section>
);
}
export function Empty({ text }: { text: string }) {
return <TableList.Item className="justify-center py-6 text-sm opacity-70">{text}</TableList.Item>;
}
interface RowActionsProps {
isDisabled: boolean;
onEdit: () => void;
onDelete: () => void;
}
export function RowActions({ isDisabled, onEdit, onDelete }: RowActionsProps) {
return (
<div className="flex shrink-0 items-center gap-1">
<Button
aria-label="Edit"
className="rounded-md p-1"
disabled={isDisabled}
onClick={onEdit}
type="button"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
aria-label="Delete"
className="rounded-md p-1"
disabled={isDisabled}
onClick={onDelete}
type="button"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
+223
View File
@@ -0,0 +1,223 @@
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>
);
}
@@ -0,0 +1,185 @@
import { useState } from "react";
import Chip from "~/components/chip";
import Link from "~/components/link";
import TableList from "~/components/table-list";
import type { Policy } from "~/utils/acl-policy";
import { asUserReference } from "~/utils/acl-policy";
import NamedListDialog, { type NamedListKind } from "../dialogs/named-list";
import { Empty, RowActions, Section } from "./editor-section";
export interface TagUsage {
tag: string;
nodes: string[];
}
interface TagsGroupsEditorProps {
policy: Policy;
onChange: (policy: Policy) => void;
isDisabled: boolean;
users: string[];
// Node names keyed by the tags currently assigned to them.
tagUsage: TagUsage[];
}
type Editing = { kind: NamedListKind; name: string | null } | null;
export default function TagsGroupsEditor({
policy,
onChange,
isDisabled,
users,
tagUsage,
}: TagsGroupsEditorProps) {
const [editing, setEditing] = useState<Editing>(null);
const groups = Object.entries(policy.groups).sort(([a], [b]) => a.localeCompare(b));
const tags = Object.entries(policy.tagOwners).sort(([a], [b]) => a.localeCompare(b));
const userSuggestions = users.map(asUserReference);
const ownerSuggestions = [...Object.keys(policy.groups), ...userSuggestions];
const record = editing?.kind === "group" ? policy.groups : policy.tagOwners;
const existingNames = Object.keys(record);
function save(name: string, members: string[]) {
if (!editing) return;
const next = { ...record };
if (editing.name !== null && editing.name !== name) {
delete next[editing.name];
}
next[name] = members;
onChange(
editing.kind === "group" ? { ...policy, groups: next } : { ...policy, tagOwners: next },
);
}
function remove(kind: NamedListKind, name: string) {
if (kind === "group") {
const groups = { ...policy.groups };
delete groups[name];
onChange({ ...policy, groups });
return;
}
const tagOwners = { ...policy.tagOwners };
delete tagOwners[name];
onChange({ ...policy, tagOwners });
}
return (
<div className="flex flex-col gap-8">
{editing ? (
<NamedListDialog
existingNames={existingNames}
isOpen
kind={editing.kind}
members={editing.name !== null ? record[editing.name] : undefined}
name={editing.name ?? undefined}
onSave={save}
setIsOpen={(open) => {
if (!open) setEditing(null);
}}
suggestions={editing.kind === "group" ? userSuggestions : ownerSuggestions}
/>
) : null}
<Section
description={
<>
Groups bundle users together so rules can refer to a team instead of individual
accounts. Membership is stored in the policy, not in Headscale.
</>
}
isDisabled={isDisabled}
onAdd={() => setEditing({ kind: "group", name: null })}
title="Groups"
>
{groups.length === 0 ? (
<Empty text="No groups are defined yet." />
) : (
groups.map(([name, members]) => (
<TableList.Item
className="flex-col items-stretch gap-2 py-3 md:flex-row md:items-center"
key={name}
>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-mono text-sm">{name}</span>
<span className="flex flex-wrap items-center gap-1">
{members.length === 0 ? (
<span className="text-xs opacity-60">No members</span>
) : (
members.map((member) => (
<Chip className="font-mono" key={member} text={member} />
))
)}
</span>
</div>
<RowActions
isDisabled={isDisabled}
onDelete={() => remove("group", name)}
onEdit={() => setEditing({ kind: "group", name })}
/>
</TableList.Item>
))
)}
</Section>
<Section
description={
<>
Tags identify machines by role instead of by owner. A tag must be declared here before
it can be assigned to a node see the{" "}
<Link external styled to="https://tailscale.com/kb/1068/acl-tags">
Tailscale tag documentation
</Link>
.
</>
}
isDisabled={isDisabled}
onAdd={() => setEditing({ kind: "tag", name: null })}
title="Tags"
>
{tags.length === 0 ? (
<Empty text="No tags are defined yet." />
) : (
tags.map(([name, owners]) => {
const usedBy = tagUsage.find((usage) => usage.tag === name)?.nodes ?? [];
return (
<TableList.Item
className="flex-col items-stretch gap-2 py-3 md:flex-row md:items-center"
key={name}
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{name}</span>
<span className="text-xs opacity-60">
{usedBy.length === 0
? "Not assigned to any machine"
: `${usedBy.length} machine${usedBy.length === 1 ? "" : "s"}: ${usedBy.join(", ")}`}
</span>
</div>
<span className="flex flex-wrap items-center gap-1">
{owners.length === 0 ? (
<span className="text-xs opacity-60">No owners</span>
) : (
owners.map((owner) => <Chip className="font-mono" key={owner} text={owner} />)
)}
</span>
</div>
<RowActions
isDisabled={isDisabled}
onDelete={() => remove("tag", name)}
onEdit={() => setEditing({ kind: "tag", name })}
/>
</TableList.Item>
);
})
)}
</Section>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Link from "~/components/link";
import Text from "~/components/text";
import Title from "~/components/title";
import TokenList from "~/components/token-list";
import { withDefaultPort, type AclRule } from "~/utils/acl-policy";
interface AclRuleDialogProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
rule?: AclRule;
sources: string[];
destinations: string[];
onSave: (rule: AclRule) => void;
}
const EMPTY: AclRule = { action: "accept", src: [], dst: [], extra: {} };
export default function AclRuleDialog({
isOpen,
setIsOpen,
rule,
sources,
destinations,
onSave,
}: AclRuleDialogProps) {
const [draft, setDraft] = useState<AclRule>(rule ?? EMPTY);
useEffect(() => {
if (isOpen) {
setDraft(rule ? structuredClone(rule) : structuredClone(EMPTY));
}
}, [isOpen, rule]);
const isInvalid = draft.src.length === 0 || draft.dst.length === 0;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel
isDisabled={isInvalid}
onSubmit={(event) => {
event.preventDefault();
// A hand-written policy may be missing the port spec Headscale wants.
onSave({ ...draft, dst: draft.dst.map(withDefaultPort) });
setIsOpen(false);
}}
>
<Title>{rule ? "Edit access rule" : "New access rule"}</Title>
<Text>
Access rules allow traffic from a set of sources to a set of destinations. Destinations
must include a port, for example <code className="font-mono">tag:web:80,443</code>. If you
leave the port out, <code className="font-mono">:*</code> is added for you. See the{" "}
<Link external styled to="https://tailscale.com/kb/1018/acls">
Tailscale ACL guide
</Link>{" "}
for the full syntax.
</Text>
<TokenList
description="Groups, tags, hosts, users or autogroups allowed to initiate the connection."
emptyText="No sources yet"
label="Sources"
onChange={(src) => setDraft({ ...draft, src })}
placeholder="group:eng"
suggestions={sources}
values={draft.src}
/>
<TokenList
description="Where the traffic is allowed to go. A destination without a port becomes :* (all ports)."
emptyText="No destinations yet"
label="Destinations"
normalize={withDefaultPort}
onChange={(dst) => setDraft({ ...draft, dst })}
placeholder="tag:web:80,443"
suggestions={destinations}
values={draft.dst}
/>
<Input
description="Optional. Restricts the rule to a single protocol (tcp, udp, icmp, ...)."
label="Protocol"
onChange={(proto) => setDraft({ ...draft, proto: proto.length > 0 ? proto : undefined })}
placeholder="tcp"
value={draft.proto ?? ""}
/>
</DialogPanel>
</Dialog>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Text from "~/components/text";
import Title from "~/components/title";
import { isValidHostName } from "~/utils/acl-policy";
interface HostDialogProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
name?: string;
value?: string;
existingNames: string[];
onSave: (name: string, value: string) => void;
}
export default function HostDialog({
isOpen,
setIsOpen,
name,
value,
existingNames,
onSave,
}: HostDialogProps) {
const [draftName, setDraftName] = useState(name ?? "");
const [draftValue, setDraftValue] = useState(value ?? "");
useEffect(() => {
if (isOpen) {
setDraftName(name ?? "");
setDraftValue(value ?? "");
}
}, [isOpen, name, value]);
const isDuplicate = draftName !== name && existingNames.includes(draftName);
const nameIsInvalid = !isValidHostName(draftName) || isDuplicate;
const valueIsInvalid = draftValue.trim().length === 0;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel
isDisabled={nameIsInvalid || valueIsInvalid}
onSubmit={(event) => {
event.preventDefault();
onSave(draftName, draftValue.trim());
setIsOpen(false);
}}
>
<Title>{name ? `Edit host ${name}` : "New host"}</Title>
<Text>
Hosts give a name to an IP address or CIDR range so it can be referenced from rules.
</Text>
<Input
errorMessage={
isDuplicate
? "A host with this name already exists."
: "Host names may only contain lowercase letters, numbers and dashes."
}
invalid={nameIsInvalid}
label="Name"
onChange={setDraftName}
placeholder="office"
value={draftName}
/>
<Input
invalid={draftValue.length > 0 && valueIsInvalid}
label="Address"
onChange={setDraftValue}
placeholder="100.64.0.0/24"
value={draftValue}
/>
</DialogPanel>
</Dialog>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Text from "~/components/text";
import Title from "~/components/title";
import TokenList from "~/components/token-list";
import { isValidGroupName, isValidTagName } from "~/utils/acl-policy";
export type NamedListKind = "group" | "tag";
interface NamedListDialogProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
kind: NamedListKind;
// Present when editing, absent when creating a new entry.
name?: string;
members?: string[];
existingNames: string[];
suggestions: string[];
onSave: (name: string, members: string[]) => void;
}
const COPY = {
group: {
title: "group",
prefix: "group:",
field: "Members",
fieldDescription: "Headscale users that belong to this group.",
empty: "No members yet",
placeholder: "alice@",
validate: isValidGroupName,
hint: "Group names must start with group: and may only contain lowercase letters, numbers and dashes.",
},
tag: {
title: "tag",
prefix: "tag:",
field: "Tag owners",
fieldDescription: "Users and groups allowed to assign this tag to a node.",
empty: "No owners yet",
placeholder: "group:ops",
validate: isValidTagName,
hint: "Tag names must start with tag: and may only contain lowercase letters, numbers and dashes.",
},
} as const;
export default function NamedListDialog({
isOpen,
setIsOpen,
kind,
name,
members,
existingNames,
suggestions,
onSave,
}: NamedListDialogProps) {
const copy = COPY[kind];
const [draftName, setDraftName] = useState(name ?? copy.prefix);
const [draftMembers, setDraftMembers] = useState<string[]>(members ?? []);
useEffect(() => {
if (isOpen) {
setDraftName(name ?? copy.prefix);
setDraftMembers(members ? [...members] : []);
}
}, [isOpen, name, members, copy.prefix]);
const trimmedName = draftName.trim();
const isDuplicate = trimmedName !== name && existingNames.includes(trimmedName);
const nameIsInvalid = !copy.validate(trimmedName) || isDuplicate;
// The field opens pre-filled with the `group:`/`tag:` prefix, which is not a
// valid name yet. Saving stays blocked, but nothing is flagged until it is edited.
const isPristine = trimmedName.length === 0 || trimmedName === copy.prefix;
const showNameError = !isPristine && nameIsInvalid;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel
isDisabled={nameIsInvalid}
onSubmit={(event) => {
event.preventDefault();
onSave(trimmedName, draftMembers);
setIsOpen(false);
}}
>
<Title>{name ? `Edit ${copy.title} ${name}` : `New ${copy.title}`}</Title>
<Text>{copy.hint}</Text>
<Input
errorMessage={isDuplicate ? `A ${copy.title} with this name already exists.` : copy.hint}
invalid={showNameError}
label="Name"
onChange={setDraftName}
placeholder={`${copy.prefix}example`}
value={draftName}
/>
<TokenList
description={copy.fieldDescription}
emptyText={copy.empty}
label={copy.field}
onChange={setDraftMembers}
placeholder={copy.placeholder}
suggestions={suggestions}
values={draftMembers}
/>
</DialogPanel>
</Dialog>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from "react";
import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input";
import Link from "~/components/link";
import Select from "~/components/select";
import Text from "~/components/text";
import Title from "~/components/title";
import TokenList from "~/components/token-list";
import { KNOWN_SSH_ACTIONS, type SshRule } from "~/utils/acl-policy";
interface SshRuleDialogProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
rule?: SshRule;
sources: string[];
destinations: string[];
onSave: (rule: SshRule) => void;
}
const EMPTY: SshRule = { action: "accept", src: [], dst: [], users: [], extra: {} };
const SSH_USERS = ["root", "autogroup:nonroot"];
export default function SshRuleDialog({
isOpen,
setIsOpen,
rule,
sources,
destinations,
onSave,
}: SshRuleDialogProps) {
const [draft, setDraft] = useState<SshRule>(rule ?? EMPTY);
useEffect(() => {
if (isOpen) {
setDraft(rule ? structuredClone(rule) : structuredClone(EMPTY));
}
}, [isOpen, rule]);
const isInvalid = draft.src.length === 0 || draft.dst.length === 0 || draft.users.length === 0;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel
isDisabled={isInvalid}
onSubmit={(event) => {
event.preventDefault();
onSave(draft);
setIsOpen(false);
}}
>
<Title>{rule ? "Edit SSH rule" : "New SSH rule"}</Title>
<Text>
SSH rules control Tailscale SSH access between nodes. Read the{" "}
<Link external styled to="https://tailscale.com/kb/1193/tailscale-ssh">
Tailscale SSH documentation
</Link>{" "}
for details about check mode.
</Text>
<Select
items={[
{ value: "accept", label: "Accept — allow the session immediately" },
{ value: "check", label: "Check — require periodic re-authentication" },
// An action we do not know is listed as-is so it survives an edit.
...(KNOWN_SSH_ACTIONS.includes(draft.action)
? []
: [{ value: draft.action, label: `${draft.action} — not known to Headplane` }]),
]}
label="Action"
onValueChange={(value) => setDraft({ ...draft, action: value ?? draft.action })}
value={draft.action}
/>
<TokenList
description="Who is allowed to open the SSH session."
emptyText="No sources yet"
label="Sources"
onChange={(src) => setDraft({ ...draft, src })}
placeholder="group:ops"
suggestions={sources}
values={draft.src}
/>
<TokenList
description="The nodes that accept the SSH session."
emptyText="No destinations yet"
label="Destinations"
onChange={(dst) => setDraft({ ...draft, dst })}
placeholder="tag:server"
suggestions={destinations}
values={draft.dst}
/>
<TokenList
description="The local Unix users that may be logged into."
emptyText="No SSH users yet"
label="SSH users"
onChange={(users) => setDraft({ ...draft, users })}
placeholder="autogroup:nonroot"
suggestions={SSH_USERS}
values={draft.users}
/>
{draft.action === "check" ? (
<Input
description="How long a check-mode session stays valid, for example 12h."
label="Check period"
onChange={(checkPeriod) =>
setDraft({ ...draft, checkPeriod: checkPeriod.length > 0 ? checkPeriod : undefined })
}
placeholder="12h"
value={draft.checkPeriod ?? ""}
/>
) : null}
</DialogPanel>
</Dialog>
);
}
+99 -4
View File
@@ -1,5 +1,14 @@
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
import { Suspense, lazy, useEffect, useState } from "react";
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
Shield,
TagsIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/button";
@@ -10,12 +19,21 @@ import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import { Tabs, TabsList, TabsPanel, TabsTab } from "~/components/tabs";
import { isApiError } from "~/server/headscale/api/error-client";
import {
parsePolicy,
policyDestinations,
policySources,
serializePolicy,
type Policy,
} from "~/utils/acl-policy";
import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import Fallback from "./components/fallback";
import RulesEditor from "./components/rules-editor";
import TagsGroupsEditor from "./components/tags-groups-editor";
const LazyEditor = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Editor })),
@@ -27,12 +45,24 @@ const LazyDiffer = lazy(() =>
export const loader = aclLoader;
export const action = aclAction;
export default function Page({ loaderData: { access, writable, policy } }: Route.ComponentProps) {
export default function Page({
loaderData: { access, writable, policy, users, tagUsage },
}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
const parsed = useMemo(() => parsePolicy(codePolicy), [codePolicy]);
const sources = useMemo(
() => (parsed.ok ? policySources(parsed.policy, users) : []),
[parsed, users],
);
const destinations = useMemo(
() => (parsed.ok ? policyDestinations(parsed.policy, users) : []),
[parsed, users],
);
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
@@ -52,6 +82,37 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
}
}, [fetcher.data]);
// The structured editors round-trip through the policy text, so the file
// editor, the diff view and Save all work off one source of truth.
function applyPolicy(next: Policy) {
setCodePolicy(serializePolicy(next));
}
function structuredPanel(render: (value: Policy) => ReactNode) {
if (!parsed.ok) {
return (
<div className="p-4">
<Notice title="Policy cannot be edited visually" variant="error">
The policy could not be parsed ({parsed.error}). Fix it in the <Code>Edit file</Code>{" "}
tab and the visual editor will come back.
</Notice>
</div>
);
}
return (
<div className="flex flex-col gap-4 p-4">
{parsed.hasComments ? (
<Notice title="Comments will be removed" variant="warning">
This policy contains comments. Saving a change made in the visual editor rewrites the
policy and drops them.
</Notice>
) : null}
{render(parsed.policy)}
</div>
);
}
return (
<div>
{!access ? (
@@ -86,8 +147,20 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
"An unknown error occurred while trying to update the ACL policy."}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor" defaultValue="edit">
<Tabs className="mb-4" label="ACL Editor" defaultValue="rules">
<TabsList>
<TabsTab value="rules">
<div className="flex items-center gap-2">
<Shield className="p-1" />
<span>Rules</span>
</div>
</TabsTab>
<TabsTab value="tags">
<div className="flex items-center gap-2">
<TagsIcon className="p-1" />
<span>Tags &amp; Groups</span>
</div>
</TabsTab>
<TabsTab value="edit">
<div className="flex items-center gap-2">
<Pencil className="p-1" />
@@ -107,6 +180,28 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
</div>
</TabsTab>
</TabsList>
<TabsPanel value="rules">
{structuredPanel((value) => (
<RulesEditor
destinations={destinations}
isDisabled={disabled}
onChange={applyPolicy}
policy={value}
sources={sources}
/>
))}
</TabsPanel>
<TabsPanel value="tags">
{structuredPanel((value) => (
<TagsGroupsEditor
isDisabled={disabled}
onChange={applyPolicy}
policy={value}
tagUsage={tagUsage}
users={users}
/>
))}
</TabsPanel>
<TabsPanel value="edit">
<Suspense fallback={<Fallback />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
@@ -27,6 +27,7 @@ interface Props {
magic?: string;
isDisabled?: boolean;
existingTags?: string[];
policyTags?: string[];
supportsNodeOwnerChange: boolean;
supportsDisablingKeyExpiry: boolean;
}
@@ -38,6 +39,7 @@ export default function MachineRow({
magic,
isDisabled,
existingTags,
policyTags,
supportsNodeOwnerChange,
supportsDisablingKeyExpiry,
}: Props) {
@@ -141,6 +143,7 @@ export default function MachineRow({
<td className="py-2 pr-0.5">
<MenuOptions
existingTags={existingTags}
policyTags={policyTags}
isDisabled={isDisabled}
magic={magic}
node={node}
+3
View File
@@ -22,6 +22,7 @@ interface MenuProps {
isFullButton?: boolean;
isDisabled?: boolean;
existingTags?: string[];
policyTags?: string[];
supportsNodeOwnerChange: boolean;
supportsDisablingKeyExpiry: boolean;
}
@@ -35,6 +36,7 @@ export default function MachineMenu({
isFullButton,
isDisabled,
existingTags,
policyTags,
supportsNodeOwnerChange,
supportsDisablingKeyExpiry,
}: MenuProps) {
@@ -85,6 +87,7 @@ export default function MachineMenu({
{modal === "tags" && (
<Tags
existingTags={existingTags}
policyTags={policyTags}
isOpen={modal === "tags"}
machine={node}
setIsOpen={(isOpen) => {
+26 -3
View File
@@ -1,4 +1,4 @@
import { Plus, TagsIcon, X } from "lucide-react";
import { AlertTriangle, Plus, TagsIcon, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFetcher } from "react-router";
@@ -17,9 +17,11 @@ interface TagsProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
existingTags?: string[];
// Tags declared under `tagOwners`. Others are assignable but match no rule.
policyTags?: string[];
}
export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
export default function Tags({ machine, isOpen, setIsOpen, existingTags, policyTags }: TagsProps) {
const fetcher = useFetcher();
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
@@ -32,6 +34,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags],
);
const undeclaredTags = useMemo(
() => (policyTags === undefined ? [] : tags.filter((entry) => !policyTags.includes(entry))),
[policyTags, tags],
);
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
@@ -96,7 +102,12 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
) : (
tags.map((item) => (
<TableList.Item className="font-mono" id={item} key={item}>
{item}
<span className="flex items-center gap-1.5">
{item}
{undeclaredTags.includes(item) ? (
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
) : null}
</span>
<Button
className="rounded-md p-0.5"
onClick={() => {
@@ -149,6 +160,18 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
))}
</div>
) : null}
{undeclaredTags.length > 0 ? (
<p className="mt-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
{undeclaredTags.join(", ")} {undeclaredTags.length === 1 ? "is" : "are"} not declared
under <code className="font-mono">tagOwners</code> in your policy, so no rule will match{" "}
{undeclaredTags.length === 1 ? "it" : "them"}. Declare{" "}
{undeclaredTags.length === 1 ? "it" : "them"} in{" "}
<Link styled to="/acls">
Access Control
</Link>
.
</p>
) : null}
<p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
+5 -1
View File
@@ -19,7 +19,7 @@ import {
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
import { extractTagOwnerTags, isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/machine";
@@ -79,6 +79,8 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
: undefined,
existingTags: sortAssignableTags(nodes, policy),
// `undefined` keeps the tag dialog from flagging every tag as undeclared.
policyTags: extractTagOwnerTags(policy),
magic,
node: enhancedNode,
stats: stats?.[enhancedNode.nodeKey],
@@ -100,6 +102,7 @@ export default function Page({
agent,
stats,
existingTags,
policyTags,
supportsNodeOwnerChange,
supportsDisablingKeyExpiry,
},
@@ -132,6 +135,7 @@ export default function Page({
</span>
<MenuOptions
existingTags={existingTags}
policyTags={policyTags}
isFullButton
magic={magic}
node={node}
+9 -1
View File
@@ -20,7 +20,12 @@ import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info";
import {
extractTagOwnerTags,
mapNodes,
sortAssignableTags,
type PopulatedNode,
} from "~/utils/node-info";
import type { Route } from "./+types/overview";
import { MachineFilters } from "./components/machine-filters";
@@ -80,6 +85,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
: undefined,
headscaleUserId: isUserPrincipal(principal) ? principal.user.headscaleUserId : undefined,
existingTags: sortAssignableTags(nodes, policy),
// `undefined` keeps the tag dialog from flagging every tag as undeclared.
policyTags: extractTagOwnerTags(policy),
magic,
nodes,
populatedNodes,
@@ -445,6 +452,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={loaderData.existingTags}
policyTags={loaderData.policyTags}
isAgent={
loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey
@@ -1,5 +1,6 @@
import { CircleUser } from "lucide-react";
import Chip from "~/components/chip";
import StatusCircle from "~/components/status-circle";
import type { Role } from "~/server/web/roles";
import cn from "~/utils/cn";
@@ -12,6 +13,9 @@ interface HeadplaneUserRowProps {
headscaleUsers: { id: string; name: string; claimed: boolean }[];
isSelf?: boolean;
isOwner?: boolean;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
export default function HeadplaneUserRow({
@@ -19,6 +23,9 @@ export default function HeadplaneUserRow({
headscaleUsers,
isSelf,
isOwner,
canEditGroups,
policyGroups,
policyHasComments,
}: HeadplaneUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
@@ -50,6 +57,13 @@ export default function HeadplaneUserRow({
{!user.headscaleUserId && (
<p className="text-xs text-amber-600 dark:text-amber-400">Not linked</p>
)}
{user.groups.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{user.groups.map((group) => (
<Chip className="font-mono" key={group} text={group} />
))}
</div>
)}
</div>
</div>
</td>
@@ -77,10 +91,13 @@ export default function HeadplaneUserRow({
</td>
<td className="py-2 pr-0.5">
<MenuOptions
canEditGroups={canEditGroups}
currentLink={user.headscaleUserId ?? undefined}
headscaleUsers={headscaleUsers}
isOwner={isOwner}
isSelf={isSelf}
policyGroups={policyGroups}
policyHasComments={policyHasComments}
user={user}
/>
</td>
@@ -5,15 +5,24 @@ import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/compo
import Delete from "../dialogs/delete-user";
import Rename from "../dialogs/rename-user";
import UserGroups from "../dialogs/user-groups";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserMenuProps {
user: UnlinkedHeadscaleUser;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
type Modal = "rename" | "delete" | null;
type Modal = "rename" | "groups" | "delete" | null;
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
export default function HeadscaleUserMenu({
user,
canEditGroups,
policyGroups,
policyHasComments,
}: HeadscaleUserMenuProps) {
const [modal, setModal] = useState<Modal>(null);
// Headscale-managed OIDC users cannot be renamed via the API.
@@ -30,6 +39,19 @@ export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
user={user}
/>
)}
{modal === "groups" && canEditGroups && (
<UserGroups
availableGroups={policyGroups ?? []}
policyHasComments={policyHasComments}
displayName={user.displayName || user.name}
groups={user.groups}
isOpen={modal === "groups"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userName={user.name}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
@@ -47,7 +69,8 @@ export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
</MenuTrigger>
<MenuContent>
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
{canRename && <MenuSeparator />}
{canEditGroups && <MenuItem onClick={() => setModal("groups")}>Edit groups</MenuItem>}
{(canRename || canEditGroups) && <MenuSeparator />}
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
@@ -1,5 +1,6 @@
import { CircleUser } from "lucide-react";
import Chip from "~/components/chip";
import StatusCircle from "~/components/status-circle";
import cn from "~/utils/cn";
@@ -9,9 +10,18 @@ import HeadscaleUserMenu from "./headscale-user-menu";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
writable?: boolean;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
export default function HeadscaleUserRow({
user,
writable,
canEditGroups,
policyGroups,
policyHasComments,
}: HeadscaleUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -34,6 +44,13 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro
<p className="leading-snug font-semibold">{displayName}</p>
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
{user.email && <p className="text-sm opacity-50">{user.email}</p>}
{user.groups.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{user.groups.map((group) => (
<Chip className="font-mono" key={group} text={group} />
))}
</div>
)}
</div>
</div>
</td>
@@ -56,7 +73,16 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
<td className="py-2 pr-0.5">
{writable ? (
<HeadscaleUserMenu
canEditGroups={canEditGroups}
policyGroups={policyGroups}
policyHasComments={policyHasComments}
user={user}
/>
) : null}
</td>
</tr>
);
}
+24 -1
View File
@@ -7,6 +7,7 @@ import Delete from "../dialogs/delete-user";
import LinkUser from "../dialogs/link-user";
import Reassign from "../dialogs/reassign-user";
import TransferOwnership from "../dialogs/transfer-ownership";
import UserGroups from "../dialogs/user-groups";
import type { HeadplaneUserData } from "../overview";
interface MenuProps {
@@ -15,9 +16,12 @@ interface MenuProps {
currentLink?: string;
isSelf?: boolean;
isOwner?: boolean;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
type Modal = "delete" | "reassign" | "link" | "transfer" | null;
type Modal = "delete" | "reassign" | "link" | "transfer" | "groups" | null;
export default function UserMenu({
user,
@@ -25,6 +29,9 @@ export default function UserMenu({
currentLink,
isSelf,
isOwner,
canEditGroups,
policyGroups,
policyHasComments,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
@@ -74,6 +81,19 @@ export default function UserMenu({
}}
/>
)}
{modal === "groups" && user.linkedHeadscaleUser && (
<UserGroups
availableGroups={policyGroups ?? []}
policyHasComments={policyHasComments}
displayName={displayName}
groups={user.groups}
isOpen={modal === "groups"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userName={user.linkedHeadscaleUser.name}
/>
)}
{modal === "transfer" && (
<TransferOwnership
isOpen={modal === "transfer"}
@@ -99,6 +119,9 @@ export default function UserMenu({
<MenuItem onClick={() => setModal("link")}>
{isLinked ? "Change linked user" : "Link Headscale user"}
</MenuItem>
{canEditGroups && user.linkedHeadscaleUser && (
<MenuItem onClick={() => setModal("groups")}>Edit groups</MenuItem>
)}
{isOwner && !isSelf && (
<>
<MenuSeparator />
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Dialog, { DialogPanel } from "~/components/dialog";
import Link from "~/components/link";
import Text from "~/components/text";
import Title from "~/components/title";
import TokenList from "~/components/token-list";
import { isValidGroupName } from "~/utils/acl-policy";
interface UserGroupsProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
// The Headscale username, which is what the ACL policy references.
userName: string;
displayName: string;
groups: string[];
availableGroups: string[];
// Whether the stored policy contains HuJSON comments, which saving drops.
policyHasComments?: boolean;
}
export default function UserGroups({
isOpen,
setIsOpen,
userName,
displayName,
groups,
availableGroups,
policyHasComments,
}: UserGroupsProps) {
const fetcher = useFetcher<{ message?: string; error?: string }>();
const submittingRef = useRef(false);
const [selected, setSelected] = useState([...groups]);
const error = fetcher.data?.error;
const isSubmitting = fetcher.state !== "idle";
useEffect(() => {
if (isOpen) {
setSelected([...groups]);
}
}, [isOpen, groups]);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
submittingRef.current = false;
if (!fetcher.data.error) {
setIsOpen(false);
}
}
}, [fetcher.data, fetcher.state]);
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
<DialogPanel
isDisabled={isSubmitting}
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
const form = new FormData();
form.set("action_id", "update_user_groups");
form.set("user_name", userName);
form.set("groups", selected.join(","));
fetcher.submit(form, { method: "POST" });
}}
>
<Title>Edit ACL groups for {displayName}</Title>
<Text>
Groups live in the ACL policy, not in Headscale. Changing them here rewrites the{" "}
<code className="font-mono">groups</code> section of your policy. See the{" "}
<Link external styled to="https://tailscale.com/kb/1018/acls">
Tailscale ACL guide
</Link>{" "}
for details.
</Text>
{policyHasComments ? (
<p className="mt-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
Your policy contains comments. Saving here rewrites the policy and drops them.
</p>
) : null}
{error ? (
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</p>
) : null}
<TokenList
emptyText="This user is not in any group"
isDisabled={isSubmitting}
label="Groups"
onChange={setSelected}
placeholder="group:example"
suggestions={availableGroups}
validate={isValidGroupName}
values={selected}
/>
</DialogPanel>
</Dialog>
);
}
+42 -1
View File
@@ -13,6 +13,7 @@ import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
import { groupsForUser, parsePolicy } from "~/utils/acl-policy";
import cn from "~/utils/cn";
import log from "~/utils/log";
import { getUserDisplayName } from "~/utils/user";
@@ -36,10 +37,12 @@ export interface HeadplaneUserData {
linkedHeadscaleUser?: User;
machines: Machine[];
profilePicUrl?: string;
groups: string[];
}
export interface UnlinkedHeadscaleUser extends User {
machines: Machine[];
groups: string[];
}
export async function loader({ request, context }: Route.LoaderArgs) {
@@ -66,6 +69,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiUsers: User[] = [];
let nodes: Machine[] = [];
let apiError: string | undefined;
let policyGroups: string[] = [];
let groupsByUser = new Map<string, string[]>();
// `write_policy` is a role capability; `file` mode refuses the write anyway.
let policyWritable = false;
let policyHasComments = false;
try {
const { api } = await getRequestApi(request);
@@ -75,6 +83,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
]);
nodes = nodesSnap.data;
apiUsers = usersSnap.data;
// Groups live in the policy, so an unreadable one just hides the UI.
try {
const { policy, updatedAt } = await api.policy.get();
// Same signal as the Access Control page: null means `file` mode.
policyWritable = updatedAt !== null;
const parsed = parsePolicy(policy);
if (parsed.ok) {
policyHasComments = parsed.hasComments;
policyGroups = Object.keys(parsed.policy.groups).sort();
groupsByUser = new Map(
apiUsers.map((user) => [user.name, groupsForUser(parsed.policy, user.name)]),
);
}
} catch (error) {
log.warn("api", "Failed to read the ACL policy for groups: %s", String(error));
}
} catch (error) {
log.warn("api", "Failed to fetch Headscale API data: %s", String(error));
apiError =
@@ -117,6 +142,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
profilePicUrl: hsUser
? resolveProfilePic(hsUser.email, hsUser.profilePicUrl)
: resolveProfilePic(hp.email ?? undefined),
groups: hsUser ? (groupsByUser.get(hsUser.name) ?? []) : [],
};
});
@@ -129,6 +155,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
...u,
machines: nodes.filter((n) => n.user?.id === u.id),
profilePicUrl: resolveProfilePic(u.email, u.profilePicUrl),
groups: groupsByUser.get(u.name) ?? [],
}));
// Build linkable Headscale users for admin link dialog
@@ -144,6 +171,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
writable: writablePermission,
canEditGroups:
writablePermission && auth.can(principal, Capabilities.write_policy) && policyWritable,
policyGroups,
policyHasComments,
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner,
oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
@@ -204,10 +235,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
>
{loaderData.headplaneUsers.map((user) => (
<HeadplaneUserRow
canEditGroups={loaderData.canEditGroups}
isSelf={user.id === loaderData.currentUserId}
isOwner={loaderData.isOwner}
key={user.id}
headscaleUsers={loaderData.headscaleUsersForLink}
policyGroups={loaderData.policyGroups}
policyHasComments={loaderData.policyHasComments}
user={user}
/>
))}
@@ -243,7 +277,14 @@ export default function Page({ loaderData }: Route.ComponentProps) {
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
<HeadscaleUserRow
canEditGroups={loaderData.canEditGroups}
key={user.id}
policyGroups={loaderData.policyGroups}
policyHasComments={loaderData.policyHasComments}
user={user}
writable={loaderData.writable}
/>
))}
</tbody>
</table>
+50
View File
@@ -1,10 +1,12 @@
import { data } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import { isValidGroupName, parsePolicy, serializePolicy, setUserGroups } from "~/utils/acl-policy";
import { validateUsername } from "~/utils/user";
import type { Route } from "./+types/overview";
@@ -142,6 +144,54 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "Headscale user linked successfully" };
}
case "update_user_groups": {
// Group membership lives in the policy, so `write_policy` is needed too.
if (!auth.can(principal, Capabilities.write_policy)) {
throw data("You do not have permission to write to the ACL policy", { status: 403 });
}
const userName = formData.get("user_name")?.toString();
if (!userName) {
throw data("Missing `user_name` in the form data.", { status: 400 });
}
const groups = (formData.get("groups")?.toString() ?? "")
.split(",")
.map((group) => group.trim())
.filter((group) => group.length > 0);
const invalid = groups.filter((group) => !isValidGroupName(group));
if (invalid.length > 0) {
return data({ error: `Invalid group name: ${invalid.join(", ")}` }, 400);
}
const { policy } = await api.policy.get();
const parsed = parsePolicy(policy);
if (!parsed.ok) {
return data({ error: `The ACL policy could not be parsed: ${parsed.error}` }, 400);
}
try {
await api.policy.set(serializePolicy(setUserGroups(parsed.policy, userName, groups)));
} catch (error) {
// Headscale refuses the write in `file` mode. The UI hides the action
// then, but a stale page can still reach this point.
const message = isDataWithApiError(error) ? error.data.rawData : String(error);
if (message.includes("update is disabled")) {
return data(
{
error:
"The ACL policy is read-only. Set `policy.mode` to `database` in your Headscale configuration to edit groups.",
},
403,
);
}
return data({ error: `Could not update the ACL policy: ${message}` }, 500);
}
return { message: "Groups updated successfully" };
}
default:
throw data("Invalid `action_id` provided.", {
status: 400,
+472
View File
@@ -0,0 +1,472 @@
import { scanHuJson } from "~/utils/node-info";
// A structured view over the Headscale ACL policy (HuJSON), which Headscale
// stores as an opaque string. What Headplane does not model is kept in `extra`.
// `action` is kept verbatim: rewriting an unknown action into `accept` would
// turn a rule we do not understand into one that allows traffic.
export interface AclRule {
action: string;
src: string[];
dst: string[];
proto?: string;
extra: Record<string, unknown>;
}
export interface SshRule {
action: string;
src: string[];
dst: string[];
users: string[];
checkPeriod?: string;
extra: Record<string, unknown>;
}
// The SSH actions the editor offers; anything else is kept and shown as-is.
export const KNOWN_SSH_ACTIONS = ["accept", "check"];
export interface Policy {
groups: Record<string, string[]>;
tagOwners: Record<string, string[]>;
hosts: Record<string, string>;
acls: AclRule[];
ssh: SshRule[];
// Top-level keys Headplane does not model (autoApprovers, nodeAttrs, ...)
extra: Record<string, unknown>;
// The order the top-level keys appeared in, so serializing keeps it.
keyOrder: string[];
}
export type ParseResult =
| { ok: true; policy: Policy; hasComments: boolean }
| { ok: false; error: string };
export const EMPTY_POLICY: Policy = {
groups: {},
tagOwners: {},
hosts: {},
acls: [],
ssh: [],
extra: {},
keyOrder: [],
};
const KNOWN_KEYS = ["groups", "tagOwners", "hosts", "acls", "ssh"];
export function parsePolicy(raw: string): ParseResult {
if (raw.trim().length === 0) {
return { ok: true, policy: structuredClone(EMPTY_POLICY), hasComments: false };
}
const { stripped, hasComments } = scanHuJson(raw);
let parsed: unknown;
try {
parsed = JSON.parse(stripped);
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : "The policy is not valid HuJSON",
};
}
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: "The policy must be a JSON object" };
}
const record = parsed as Record<string, unknown>;
const extra: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
if (!KNOWN_KEYS.includes(key)) {
extra[key] = value;
}
}
return {
ok: true,
hasComments,
policy: {
groups: toStringListMap(record.groups),
tagOwners: toStringListMap(record.tagOwners),
hosts: toStringMap(record.hosts),
acls: toAclRules(record.acls),
ssh: toSshRules(record.ssh),
extra,
keyOrder: Object.keys(record),
},
};
}
export function serializePolicy(policy: Policy): string {
const sections: Record<string, unknown> = {};
// Insertion order is preserved so an edit does not reshuffle the rest.
if (Object.keys(policy.groups).length > 0) sections.groups = policy.groups;
if (Object.keys(policy.tagOwners).length > 0) sections.tagOwners = policy.tagOwners;
if (Object.keys(policy.hosts).length > 0) sections.hosts = policy.hosts;
if (policy.acls.length > 0) sections.acls = policy.acls.map(compactAclRule);
if (policy.ssh.length > 0) sections.ssh = policy.ssh.map(compactSshRule);
for (const [key, value] of Object.entries(policy.extra)) {
sections[key] = value;
}
const out: Record<string, unknown> = {};
for (const key of policy.keyOrder) {
if (key in sections) {
out[key] = sections[key];
}
}
// Sections that did not exist before are appended.
for (const [key, value] of Object.entries(sections)) {
if (!(key in out)) {
out[key] = value;
}
}
return `${format(out, 0)}\n`;
}
// MARK: Catalog helpers
export function policySources(policy: Policy, users: string[]): string[] {
return unique([
"*",
"autogroup:member",
"autogroup:admin",
...Object.keys(policy.groups),
...Object.keys(policy.tagOwners),
...Object.keys(policy.hosts),
...users.map(asUserReference),
]);
}
// Destinations without their port spec; the rule editor appends it.
export function policyDestinations(policy: Policy, users: string[]): string[] {
return unique([
"*",
"autogroup:internet",
"autogroup:self",
...Object.keys(policy.groups),
...Object.keys(policy.tagOwners),
...Object.keys(policy.hosts),
...users.map(asUserReference),
]);
}
// Headscale references users as "name@" in policies.
export function asUserReference(user: string): string {
return user.endsWith("@") ? user : `${user}@`;
}
// `*`, a single port, a range, or a comma separated list of either.
const PORT_SPEC = /^(\*|\d{1,5}(-\d{1,5})?(,\d{1,5}(-\d{1,5})?)*)$/;
// Headscale splits a destination on its *last* colon, so `fd7a::1:22` is
// `fd7a::1` on port 22. The tail only counts as a port when what precedes it is
// a destination in its own right, which keeps `fd7a::1` (head `fd7a:`) intact.
export function hasPortSpec(destination: string): boolean {
const lastColon = destination.lastIndexOf(":");
if (lastColon <= 0 || lastColon === destination.length - 1) {
return false;
}
if (!PORT_SPEC.test(destination.slice(lastColon + 1))) {
return false;
}
return isCompleteDestination(destination.slice(0, lastColon));
}
const ALIAS_PREFIXES = ["tag:", "group:", "autogroup:"];
// Only a prefixed alias or an IPv6 address carries an inner colon.
function isCompleteDestination(value: string): boolean {
if (value.length === 0 || value.endsWith(":")) {
return false;
}
if (ALIAS_PREFIXES.some((prefix) => value.startsWith(prefix)) || !value.includes(":")) {
return true;
}
// Headscale does not accept the bracketed form, but a hand-written policy
// may use it and appending a port would only make it worse.
if (value.startsWith("[") && value.endsWith("]")) {
return isIpv6(value.slice(1, -1));
}
return isIpv6(value);
}
const IPV6_GROUP = /^[0-9a-fA-F]{1,4}$/;
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
// Enough to tell an address or prefix apart from an alias, not a validator.
function isIpv6(value: string): boolean {
const [address, prefixLength, ...rest] = value.split("/");
if (rest.length > 0 || (prefixLength !== undefined && !/^\d{1,3}$/.test(prefixLength))) {
return false;
}
const halves = address.split("::");
if (halves.length > 2) {
return false;
}
const groups = halves.flatMap((half) => (half.length === 0 ? [] : half.split(":")));
if (groups.length === 0) {
// The unspecified address, `::`.
return halves.length === 2;
}
const last = groups[groups.length - 1];
const head = IPV4.test(last) ? groups.slice(0, -1) : groups;
if (!head.every((group) => IPV6_GROUP.test(group))) {
return false;
}
// An embedded IPv4 tail fills the last two groups.
const width = IPV4.test(last) ? head.length + 2 : groups.length;
return halves.length === 2 ? width <= 7 : width === 8;
}
// Headscale rejects a destination without a port, so one gets `:*`.
export function withDefaultPort(destination: string): string {
const trimmed = destination.trim();
if (trimmed.length === 0 || hasPortSpec(trimmed)) {
return trimmed;
}
return `${trimmed}:*`;
}
export function groupsForUser(policy: Policy, userName: string): string[] {
const reference = asUserReference(userName);
return Object.entries(policy.groups)
.filter(([, members]) => members.includes(reference) || members.includes(userName))
.map(([group]) => group)
.sort();
}
export function setUserGroups(policy: Policy, userName: string, groups: string[]): Policy {
const reference = asUserReference(userName);
const next: Record<string, string[]> = {};
for (const [group, members] of Object.entries(policy.groups)) {
const isMember = members.includes(reference) || members.includes(userName);
const shouldBeMember = groups.includes(group);
if (isMember === shouldBeMember) {
// Leave the member list untouched so the policy diff stays minimal.
next[group] = members;
continue;
}
next[group] = shouldBeMember
? [...members, reference]
: members.filter((member) => member !== reference && member !== userName);
}
// Groups that don't exist yet are created with this user as the only member.
for (const group of groups) {
if (!(group in next)) {
next[group] = [reference];
}
}
return { ...policy, groups: next };
}
// MARK: Validation
export function isValidGroupName(name: string): boolean {
return /^group:[a-z0-9][a-z0-9-]*$/.test(name);
}
export function isValidTagName(name: string): boolean {
return /^tag:[a-z0-9][a-z0-9-]*$/.test(name);
}
export function isValidHostName(name: string): boolean {
return /^[a-z0-9][a-z0-9-]*$/.test(name);
}
// MARK: Internals
function toStringListMap(value: unknown): Record<string, string[]> {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const out: Record<string, string[]> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
out[key] = toStringList(entry);
}
return out;
}
function toStringMap(value: unknown): Record<string, string> {
if (value == null || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const out: Record<string, string> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (typeof entry === "string") {
out[key] = entry;
}
}
return out;
}
function toStringList(value: unknown): string[] {
if (typeof value === "string") {
return [value];
}
if (!Array.isArray(value)) {
return [];
}
return value.filter((entry): entry is string => typeof entry === "string");
}
const ACL_RULE_KEYS = ["action", "src", "dst", "proto"];
const SSH_RULE_KEYS = ["action", "src", "dst", "users", "checkPeriod"];
function toAclRules(value: unknown): AclRule[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is Record<string, unknown> => entry != null && typeof entry === "object")
.map((entry) => {
const rule: AclRule = {
action: typeof entry.action === "string" ? entry.action : "accept",
src: toStringList(entry.src),
dst: toStringList(entry.dst),
extra: extraKeys(entry, ACL_RULE_KEYS),
};
if (typeof entry.proto === "string" && entry.proto.length > 0) {
rule.proto = entry.proto;
}
return rule;
});
}
function toSshRules(value: unknown): SshRule[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is Record<string, unknown> => entry != null && typeof entry === "object")
.map((entry) => {
const rule: SshRule = {
action: typeof entry.action === "string" ? entry.action : "accept",
src: toStringList(entry.src),
dst: toStringList(entry.dst),
users: toStringList(entry.users),
extra: extraKeys(entry, SSH_RULE_KEYS),
};
if (typeof entry.checkPeriod === "string" && entry.checkPeriod.length > 0) {
rule.checkPeriod = entry.checkPeriod;
}
return rule;
});
}
// Fields with no editor — `srcPosture`, `acceptEnv` — ride along untouched.
function extraKeys(entry: Record<string, unknown>, known: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(entry)) {
if (!known.includes(key)) {
out[key] = value;
}
}
return out;
}
function compactAclRule(rule: AclRule): Record<string, unknown> {
const out: Record<string, unknown> = { action: rule.action, src: rule.src, dst: rule.dst };
if (rule.proto) out.proto = rule.proto;
return { ...out, ...rule.extra };
}
function compactSshRule(rule: SshRule): Record<string, unknown> {
const out: Record<string, unknown> = {
action: rule.action,
src: rule.src,
dst: rule.dst,
users: rule.users,
};
if (rule.checkPeriod) out.checkPeriod = rule.checkPeriod;
return { ...out, ...rule.extra };
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter((value) => value.length > 0)));
}
// Rules wider than this are broken across multiple lines.
const INLINE_WIDTH = 120;
// Keeps arrays of primitives, and short rule objects, on one line, the way
// Tailscale and Headscale policy examples are written.
function format(value: unknown, depth: number, allowInline = false): string {
const indent = " ".repeat(depth);
const inner = " ".repeat(depth + 1);
if (Array.isArray(value)) {
if (value.length === 0) {
return "[]";
}
if (value.every(isPrimitive)) {
return `[${value.map((entry) => JSON.stringify(entry)).join(", ")}]`;
}
// Rules live inside arrays, and those are the objects worth inlining.
const entries = value.map((entry) => `${inner}${format(entry, depth + 1, true)}`);
return `[\n${entries.join(",\n")}\n${indent}]`;
}
if (value != null && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return "{}";
}
const inline = allowInline ? inlineObject(entries) : undefined;
if (inline !== undefined && indent.length + inline.length <= INLINE_WIDTH) {
return inline;
}
const body = entries.map(
([key, entry]) => `${inner}${JSON.stringify(key)}: ${format(entry, depth + 1)}`,
);
return `{\n${body.join(",\n")}\n${indent}}`;
}
return JSON.stringify(value);
}
// Returns undefined when the object has to be expanded.
function inlineObject(entries: [string, unknown][]): string | undefined {
const parts: string[] = [];
for (const [key, value] of entries) {
if (isPrimitive(value)) {
parts.push(`${JSON.stringify(key)}: ${JSON.stringify(value)}`);
continue;
}
if (Array.isArray(value) && value.every(isPrimitive)) {
parts.push(
`${JSON.stringify(key)}: [${value.map((entry) => JSON.stringify(entry)).join(", ")}]`,
);
continue;
}
return undefined;
}
return `{ ${parts.join(", ")} }`;
}
function isPrimitive(value: unknown): boolean {
return value === null || typeof value !== "object";
}
+37 -18
View File
@@ -50,35 +50,52 @@ export function sortNodeTags(nodes: Machine[]): string[] {
}
export function sortAssignableTags(nodes: Machine[], policy?: string): string[] {
return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort();
return Array.from(
new Set([...sortNodeTags(nodes), ...(extractTagOwnerTags(policy) ?? [])]),
).sort();
}
export function extractTagOwnerTags(policy: string | undefined): string[] {
if (!policy) {
// The tags declared under `tagOwners`. An empty list means the policy declares
// none; `undefined` means the policy could not be read or parsed.
export function extractTagOwnerTags(policy: string | undefined): string[] | undefined {
if (policy === undefined) {
return undefined;
}
if (policy.trim().length === 0) {
return [];
}
let parsed: unknown;
try {
const parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy)) as unknown;
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
return [];
}
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
return [];
}
return Object.keys(tagOwners)
.filter((tag) => tag.startsWith("tag:"))
.sort();
parsed = JSON.parse(stripJsonCommentsAndTrailingCommas(policy));
} catch {
return undefined;
}
if (parsed == null || typeof parsed !== "object" || !("tagOwners" in parsed)) {
return [];
}
const tagOwners = (parsed as { tagOwners?: unknown }).tagOwners;
if (tagOwners == null || typeof tagOwners !== "object" || Array.isArray(tagOwners)) {
return [];
}
return Object.keys(tagOwners)
.filter((tag) => tag.startsWith("tag:"))
.sort();
}
function stripJsonCommentsAndTrailingCommas(input: string): string {
export function stripJsonCommentsAndTrailingCommas(input: string): string {
return scanHuJson(input).stripped;
}
// Strips comments and trailing commas, and reports whether the input carried
// comments — a rewrite of the policy drops those, so callers warn first.
export function scanHuJson(input: string): { stripped: string; hasComments: boolean } {
let output = "";
let hasComments = false;
let inString = false;
let escaped = false;
let inLineComment = false;
@@ -124,12 +141,14 @@ function stripJsonCommentsAndTrailingCommas(input: string): string {
if (char === "/" && next === "/") {
inLineComment = true;
hasComments = true;
i++;
continue;
}
if (char === "/" && next === "*") {
inBlockComment = true;
hasComments = true;
i++;
continue;
}
@@ -137,5 +156,5 @@ function stripJsonCommentsAndTrailingCommas(input: string): string {
output += char;
}
return output.replace(/,\s*([}\]])/g, "$1");
return { stripped: output.replace(/,\s*([}\]])/g, "$1"), hasComments };
}
+1
View File
@@ -55,6 +55,7 @@ export default defineConfig({
link: "/features/sso",
items: [{ text: "Proxy Authentication", link: "/features/proxy-auth" }],
},
{ text: "Access Control", link: "/features/acls" },
{ text: "Headplane Agent", link: "/features/agent" },
{ text: "Browser SSH", link: "/features/ssh" },
],
+83
View File
@@ -0,0 +1,83 @@
---
title: Access Control
description: Edit the Headscale ACL policy, tags and groups from the Headplane UI.
---
# Access Control
Headscale stores its Access Control List (ACL) as a single HuJSON policy. The
**Access Control** page in Headplane exposes that policy in two ways: a
structured editor for the parts most people change day to day, and the raw file
editor for everything else.
## Requirements
The policy can only be written through the web UI when Headscale runs in
database policy mode:
```yaml
# Headscale config.yaml
policy:
mode: database
```
In `file` mode the page is read-only and shows a notice explaining why. Editing
also requires the `write_policy` capability, which the `owner`, `admin` and
`network_admin` roles have.
## Rules
The **Rules** tab renders the policy as three lists:
- **Access rules** — the `acls` section. Each rule allows traffic from a set of
sources to a set of destinations. Destinations include a port range, for
example `tag:web:80,443`. A destination entered without one gets `:*`
appended, since Headscale rejects a destination that has no port.
- **SSH rules** — the `ssh` section, including `check` mode and its check
period.
- **Hosts** — the `hosts` section, which names an IP address or CIDR range so
rules can reference it.
Adding or editing an entry opens a dialog where sources and destinations are
built from chips. Every group, tag, host and Headscale user already known to
your tailnet is offered as a one-click suggestion, so rules can be written
without memorising the syntax.
## Tags and groups
The **Tags & Groups** tab manages the `groups` and `tagOwners` sections.
- **Groups** bundle Headscale users so rules can refer to a team. Members are
written as `username@`, which is how Headscale references users in a policy.
- **Tags** identify machines by role rather than by owner. Each tag lists the
users and groups allowed to assign it. The list also shows which machines
currently carry the tag.
Tags must exist under `tagOwners` before they mean anything: assigning an
undeclared tag to a machine is allowed by Headscale, but no rule will ever match
it. The tag dialog on the **Machines** page flags such tags with a warning and
links back here.
Group membership can also be edited from the **Users** page: the row menu has an
**Edit groups** entry, and the groups a user belongs to are shown under their
name. Both surfaces write to the same `groups` section of the policy.
## Editing the file directly
The **Edit file** tab is the original CodeMirror editor over the raw policy, and
**Preview changes** shows a diff against the saved version. The structured
editors write into the same buffer, so a change made visually shows up in the
file editor and in the diff before it is saved.
Nothing is sent to Headscale until **Save** is pressed.
::: warning Comments are not preserved
HuJSON allows comments and trailing commas. Headplane reads them, but the
structured editors regenerate the policy text, which drops comments. The Rules
and Tags & Groups tabs show a notice when the loaded policy contains comments —
use the file editor if you want to keep them.
:::
Unknown top-level keys such as `autoApprovers` and `nodeAttrs` are preserved
untouched, so using the visual editor never silently drops parts of a policy
that Headplane does not model.
+337
View File
@@ -0,0 +1,337 @@
import { describe, expect, test } from "vitest";
import {
asUserReference,
groupsForUser,
hasPortSpec,
isValidGroupName,
isValidHostName,
isValidTagName,
parsePolicy,
policyDestinations,
policySources,
serializePolicy,
setUserGroups,
withDefaultPort,
} from "~/utils/acl-policy";
const POLICY = `{
// Teams that can be referenced from rules
"groups": {
"group:eng": ["alice@", "bob@"],
"group:ops": ["ops@"]
},
"tagOwners": {
"tag:server": ["group:ops"]
},
"hosts": {
"office": "100.64.0.0/24"
},
"acls": [
{ "action": "accept", "src": ["group:eng"], "dst": ["tag:server:22"] }
],
"ssh": [
{ "action": "check", "src": ["group:ops"], "dst": ["tag:server"], "users": ["root"], "checkPeriod": "12h" }
],
"autoApprovers": {
"routes": { "10.0.0.0/8": ["group:ops"] }
}
}`;
function parseOrThrow(raw: string) {
const result = parsePolicy(raw);
if (!result.ok) {
throw new Error(result.error);
}
return result;
}
describe("parsePolicy", () => {
test("parses an empty policy into an empty model", () => {
const result = parseOrThrow("");
expect(result.policy).toEqual({
groups: {},
tagOwners: {},
hosts: {},
acls: [],
ssh: [],
extra: {},
keyOrder: [],
});
expect(result.hasComments).toBe(false);
});
test("does not report comments for a policy that only has trailing commas", () => {
const result = parseOrThrow(`{
"groups": { "group:eng": ["alice@"], },
}`);
expect(result.hasComments).toBe(false);
});
test("parses HuJSON with comments and trailing commas", () => {
const result = parseOrThrow(`{
"groups": { "group:eng": ["alice@"], }, // a comment
}`);
expect(result.policy.groups).toEqual({ "group:eng": ["alice@"] });
expect(result.hasComments).toBe(true);
});
test("parses every known section", () => {
const { policy } = parseOrThrow(POLICY);
expect(policy.groups).toEqual({
"group:eng": ["alice@", "bob@"],
"group:ops": ["ops@"],
});
expect(policy.tagOwners).toEqual({ "tag:server": ["group:ops"] });
expect(policy.hosts).toEqual({ office: "100.64.0.0/24" });
expect(policy.acls).toEqual([
{ action: "accept", src: ["group:eng"], dst: ["tag:server:22"], extra: {} },
]);
expect(policy.ssh).toEqual([
{
action: "check",
src: ["group:ops"],
dst: ["tag:server"],
users: ["root"],
checkPeriod: "12h",
extra: {},
},
]);
});
test("keeps rule actions and unknown rule keys as they were written", () => {
const { policy } = parseOrThrow(`{
"acls": [
{ "action": "deny", "src": ["group:eng"], "dst": ["tag:server:22"], "srcPosture": ["posture:latest"] }
],
"ssh": [
{ "action": "reject", "src": ["group:ops"], "dst": ["tag:server"], "users": ["root"], "acceptEnv": ["TERM"] }
]
}`);
expect(policy.acls[0].action).toBe("deny");
expect(policy.acls[0].extra).toEqual({ srcPosture: ["posture:latest"] });
expect(policy.ssh[0].action).toBe("reject");
expect(policy.ssh[0].extra).toEqual({ acceptEnv: ["TERM"] });
// An action the editor does not know must survive a round trip: rewriting
// it as "accept" would widen the policy behind the operator's back.
const serialized = serializePolicy(policy);
expect(serialized).toContain('"action": "deny"');
expect(serialized).toContain('"srcPosture": ["posture:latest"]');
expect(serialized).toContain('"acceptEnv": ["TERM"]');
});
test("keeps unknown top-level keys in extra", () => {
const { policy } = parseOrThrow(POLICY);
expect(policy.extra).toEqual({
autoApprovers: { routes: { "10.0.0.0/8": ["group:ops"] } },
});
});
test("reports invalid JSON instead of throwing", () => {
const result = parsePolicy("{ not json");
expect(result.ok).toBe(false);
});
test("rejects a policy that is not an object", () => {
const result = parsePolicy("[]");
expect(result).toEqual({ ok: false, error: "The policy must be a JSON object" });
});
test("tolerates sections with the wrong shape", () => {
const { policy } = parseOrThrow(`{ "groups": "nope", "acls": { "a": 1 }, "hosts": [] }`);
expect(policy.groups).toEqual({});
expect(policy.acls).toEqual([]);
expect(policy.hosts).toEqual({});
});
});
describe("serializePolicy", () => {
test("round-trips a policy without losing data", () => {
const { policy } = parseOrThrow(POLICY);
const { policy: again } = parseOrThrow(serializePolicy(policy));
expect(again).toEqual(policy);
});
test("keeps rules on a single line and preserves key order", () => {
const { policy } = parseOrThrow(POLICY);
const output = serializePolicy(policy);
expect(output).toContain(
` { "action": "accept", "src": ["group:eng"], "dst": ["tag:server:22"] }`,
);
expect(output.indexOf(`"groups"`)).toBeLessThan(output.indexOf(`"tagOwners"`));
expect(output.endsWith("\n")).toBe(true);
});
test("omits empty sections", () => {
const { policy } = parseOrThrow(`{ "groups": { "group:eng": ["alice@"] } }`);
const output = serializePolicy(policy);
expect(output).toContain(`"groups"`);
expect(output).not.toContain(`"acls"`);
expect(output).not.toContain(`"hosts"`);
});
test("writes unknown keys back out", () => {
const { policy } = parseOrThrow(POLICY);
expect(serializePolicy(policy)).toContain(`"autoApprovers"`);
});
test("keeps the original top-level section order", () => {
const { policy } = parseOrThrow(`{
"ssh": [{ "action": "accept", "src": ["group:ops"], "dst": ["tag:server"], "users": ["root"] }],
"hosts": { "office": "100.64.0.0/24" },
"groups": { "group:eng": ["alice@"] }
}`);
const output = serializePolicy(policy);
expect(output.indexOf(`"ssh"`)).toBeLessThan(output.indexOf(`"hosts"`));
expect(output.indexOf(`"hosts"`)).toBeLessThan(output.indexOf(`"groups"`));
});
test("appends a section that did not exist before", () => {
const { policy } = parseOrThrow(`{ "hosts": { "office": "100.64.0.0/24" } }`);
const output = serializePolicy({
...policy,
groups: { "group:eng": ["alice@"] },
});
expect(output.indexOf(`"hosts"`)).toBeLessThan(output.indexOf(`"groups"`));
});
});
describe("group membership", () => {
test("finds the groups a user belongs to", () => {
const { policy } = parseOrThrow(POLICY);
expect(groupsForUser(policy, "alice")).toEqual(["group:eng"]);
expect(groupsForUser(policy, "ops")).toEqual(["group:ops"]);
expect(groupsForUser(policy, "nobody")).toEqual([]);
});
test("adds a user to a group without reordering the existing members", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "ops", ["group:eng", "group:ops"]);
expect(next.groups["group:eng"]).toEqual(["alice@", "bob@", "ops@"]);
expect(next.groups["group:ops"]).toEqual(["ops@"]);
});
test("removes a user from groups that are no longer selected", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "alice", []);
expect(next.groups["group:eng"]).toEqual(["bob@"]);
});
test("creates a group that does not exist yet", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "alice", ["group:eng", "group:new"]);
expect(next.groups["group:new"]).toEqual(["alice@"]);
});
test("is a no-op when membership does not change", () => {
const { policy } = parseOrThrow(POLICY);
const next = setUserGroups(policy, "alice", ["group:eng"]);
expect(next.groups).toEqual(policy.groups);
});
});
describe("catalog helpers", () => {
test("suggests groups, tags, hosts and users as sources", () => {
const { policy } = parseOrThrow(POLICY);
const sources = policySources(policy, ["alice", "ops"]);
expect(sources).toEqual(
expect.arrayContaining(["group:eng", "tag:server", "office", "alice@", "ops@"]),
);
});
test("suggests autogroups only where they are valid", () => {
const { policy } = parseOrThrow(POLICY);
expect(policySources(policy, [])).toContain("autogroup:member");
expect(policyDestinations(policy, [])).toContain("autogroup:internet");
expect(policyDestinations(policy, [])).not.toContain("autogroup:member");
});
test("normalizes user references", () => {
expect(asUserReference("alice")).toBe("alice@");
expect(asUserReference("alice@")).toBe("alice@");
});
});
describe("destination ports", () => {
test("appends :* when no port is given", () => {
expect(withDefaultPort("tag:web")).toBe("tag:web:*");
expect(withDefaultPort("group:eng")).toBe("group:eng:*");
expect(withDefaultPort("autogroup:internet")).toBe("autogroup:internet:*");
expect(withDefaultPort("alice@")).toBe("alice@:*");
expect(withDefaultPort("office")).toBe("office:*");
expect(withDefaultPort("*")).toBe("*:*");
expect(withDefaultPort("100.64.0.0/24")).toBe("100.64.0.0/24:*");
});
test("leaves an existing port spec alone", () => {
expect(withDefaultPort("tag:web:*")).toBe("tag:web:*");
expect(withDefaultPort("tag:web:80")).toBe("tag:web:80");
expect(withDefaultPort("tag:web:80,443")).toBe("tag:web:80,443");
expect(withDefaultPort("tag:web:8000-8080")).toBe("tag:web:8000-8080");
expect(withDefaultPort("tag:web:22,8000-8080")).toBe("tag:web:22,8000-8080");
expect(withDefaultPort("*:*")).toBe("*:*");
});
test("treats a bare IPv6 address as unported", () => {
expect(withDefaultPort("fd7a:115c:a1e0::1")).toBe("fd7a:115c:a1e0::1:*");
expect(withDefaultPort("fd7a::1")).toBe("fd7a::1:*");
expect(withDefaultPort("fd7a::/48")).toBe("fd7a::/48:*");
});
test("keeps the port of a bracketless IPv6 destination", () => {
// Headscale splits on the last colon, so this is `fd7a::1` on port 22 and
// appending `:*` would change which port the rule opens.
expect(withDefaultPort("fd7a::1:22")).toBe("fd7a::1:22");
expect(withDefaultPort("fd7a:115c:a1e0::1:80,443")).toBe("fd7a:115c:a1e0::1:80,443");
expect(hasPortSpec("fd7a::1:22")).toBe(true);
});
test("leaves a bracketed IPv6 destination alone", () => {
expect(withDefaultPort("[fd7a:115c:a1e0::1]:22")).toBe("[fd7a:115c:a1e0::1]:22");
});
test("trims and ignores empty input", () => {
expect(withDefaultPort(" tag:web ")).toBe("tag:web:*");
expect(withDefaultPort(" ")).toBe("");
});
test("reports whether a port spec is present", () => {
expect(hasPortSpec("tag:web:80")).toBe(true);
expect(hasPortSpec("group:eng:*")).toBe(true);
expect(hasPortSpec("100.64.0.1:22")).toBe(true);
expect(hasPortSpec("tag:web")).toBe(false);
expect(hasPortSpec("fd7a::1")).toBe(false);
expect(hasPortSpec("alice@")).toBe(false);
});
});
describe("validation", () => {
test("accepts well-formed names", () => {
expect(isValidGroupName("group:eng-team")).toBe(true);
expect(isValidTagName("tag:web-01")).toBe(true);
expect(isValidHostName("office-2")).toBe(true);
});
test("rejects malformed names", () => {
expect(isValidGroupName("eng")).toBe(false);
expect(isValidGroupName("group:")).toBe(false);
expect(isValidGroupName("group:Eng")).toBe(false);
expect(isValidTagName("group:eng")).toBe(false);
expect(isValidHostName("tag:web")).toBe(false);
});
});
+10 -2
View File
@@ -41,8 +41,16 @@ describe("extractTagOwnerTags", () => {
).toEqual(["tag:prod", "tag:server"]);
});
test("ignores invalid policies", () => {
expect(extractTagOwnerTags("not-json")).toEqual([]);
test("returns undefined when the policy cannot be read or parsed", () => {
// An unreadable policy is not the same as one declaring no tags: callers
// use `undefined` to keep the tag dialog from flagging every tag.
expect(extractTagOwnerTags(undefined)).toBeUndefined();
expect(extractTagOwnerTags("not-json")).toBeUndefined();
});
test("returns an empty list when the policy declares no tags", () => {
expect(extractTagOwnerTags("")).toEqual([]);
expect(extractTagOwnerTags('{ "groups": { "group:eng": ["alice@"] } }')).toEqual([]);
});
});