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