mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 00:47:17 +00:00
feat(ui): structured editor for ACL rules, tags and groups (#608)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user