From 6ebe90df666477a113108e28323f58fc8313104b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberindo=20Loffr=C3=A8?= Date: Tue, 18 Aug 2026 00:13:18 +0200 Subject: [PATCH 1/6] feat(acls): add a structured model for the ACL policy Headscale stores the policy as an opaque HuJSON string. Parse it into a typed model that the UI can edit, and serialize it back in a shape that stays close to a hand-written policy: rules on a single line, key order preserved, empty sections omitted. Top-level keys Headplane does not model (autoApprovers, nodeAttrs, ...) are round-tripped untouched so editing never silently drops them. Also exposes helpers the editors need: the source/destination catalog, group membership lookups, name validation, and `withDefaultPort`, which appends `:*` to a destination that has no port spec since Headscale rejects those. Co-Authored-By: Claude Opus 5 (1M context) --- app/utils/acl-policy.ts | 386 ++++++++++++++++++++++++++++ app/utils/node-info.ts | 2 +- tests/unit/utils/acl-policy.test.ts | 264 +++++++++++++++++++ 3 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 app/utils/acl-policy.ts create mode 100644 tests/unit/utils/acl-policy.test.ts diff --git a/app/utils/acl-policy.ts b/app/utils/acl-policy.ts new file mode 100644 index 00000000..aece4dae --- /dev/null +++ b/app/utils/acl-policy.ts @@ -0,0 +1,386 @@ +import { stripJsonCommentsAndTrailingCommas } from "~/utils/node-info"; + +// A structured view over the Headscale ACL policy (HuJSON). Headscale stores +// the policy as an opaque string, so the visual editor parses it into this +// model, mutates it, and serializes it back. Unknown top-level keys are kept +// in `extra` so editing a policy never drops fields Headplane doesn't know. + +export interface AclRule { + action: "accept"; + src: string[]; + dst: string[]; + proto?: string; +} + +export interface SshRule { + action: "accept" | "check"; + src: string[]; + dst: string[]; + users: string[]; + checkPeriod?: string; +} + +export interface Policy { + groups: Record; + tagOwners: Record; + hosts: Record; + acls: AclRule[]; + ssh: SshRule[]; + // Top-level keys Headplane does not model (autoApprovers, nodeAttrs, ...) + extra: Record; +} + +export type ParseResult = + | { ok: true; policy: Policy; hasComments: boolean } + | { ok: false; error: string }; + +export const EMPTY_POLICY: Policy = { + groups: {}, + tagOwners: {}, + hosts: {}, + acls: [], + ssh: [], + extra: {}, +}; + +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 = stripJsonCommentsAndTrailingCommas(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; + const extra: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (!KNOWN_KEYS.includes(key)) { + extra[key] = value; + } + } + + return { + ok: true, + hasComments: stripped.length !== raw.length, + policy: { + groups: toStringListMap(record.groups), + tagOwners: toStringListMap(record.tagOwners), + hosts: toStringMap(record.hosts), + acls: toAclRules(record.acls), + ssh: toSshRules(record.ssh), + extra, + }, + }; +} + +export function serializePolicy(policy: Policy): string { + const out: Record = {}; + + // Insertion order is preserved so editing one entry doesn't reshuffle a + // policy the operator wrote by hand. + if (Object.keys(policy.groups).length > 0) out.groups = policy.groups; + if (Object.keys(policy.tagOwners).length > 0) out.tagOwners = policy.tagOwners; + if (Object.keys(policy.hosts).length > 0) out.hosts = policy.hosts; + if (policy.acls.length > 0) out.acls = policy.acls.map(compactAclRule); + if (policy.ssh.length > 0) out.ssh = policy.ssh.map(compactSshRule); + for (const [key, value] of Object.entries(policy.extra)) { + out[key] = value; + } + + return `${format(out, 0)}\n`; +} + +// MARK: Catalog helpers + +// Everything that can be used as a source in an ACL rule. +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), + ]); +} + +// Everything that can be used as a destination in an ACL rule. Ports are +// appended by the rule editor, so the raw identities are returned here. +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 port spec is the trailing `:...` of a destination: `*`, 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})?)*)$/; + +// Whether a destination already carries a port spec. Bare IPv6 addresses are +// never treated as ported: `fd7a::1` ends in something that looks like a port, +// so a port on an IPv6 destination has to be written as `[fd7a::1]:22`. +export function hasPortSpec(destination: string): boolean { + if (destination.includes("::") && !destination.includes("]")) { + return false; + } + + return PORT_SPEC.test(destination); +} + +// ACL destinations must specify ports; Headscale rejects the rule otherwise. +// Anything typed or picked without one gets `:*`, which is what people mean. +export function withDefaultPort(destination: string): string { + const trimmed = destination.trim(); + if (trimmed.length === 0 || hasPortSpec(trimmed)) { + return trimmed; + } + + return `${trimmed}:*`; +} + +// The groups a given Headscale user belongs to. +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(); +} + +// Replaces the full group membership of a user in one pass. +export function setUserGroups(policy: Policy, userName: string, groups: string[]): Policy { + const reference = asUserReference(userName); + const next: Record = {}; + + 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 { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = toStringList(entry); + } + return out; +} + +function toStringMap(value: unknown): Record { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + 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"); +} + +function toAclRules(value: unknown): AclRule[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((entry): entry is Record => entry != null && typeof entry === "object") + .map((entry) => { + const rule: AclRule = { + action: "accept", + src: toStringList(entry.src), + dst: toStringList(entry.dst), + }; + 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 => entry != null && typeof entry === "object") + .map((entry) => { + const rule: SshRule = { + action: entry.action === "check" ? "check" : "accept", + src: toStringList(entry.src), + dst: toStringList(entry.dst), + users: toStringList(entry.users), + }; + if (typeof entry.checkPeriod === "string" && entry.checkPeriod.length > 0) { + rule.checkPeriod = entry.checkPeriod; + } + return rule; + }); +} + +function compactAclRule(rule: AclRule): Record { + const out: Record = { action: rule.action, src: rule.src, dst: rule.dst }; + if (rule.proto) out.proto = rule.proto; + return out; +} + +function compactSshRule(rule: SshRule): Record { + const out: Record = { + action: rule.action, + src: rule.src, + dst: rule.dst, + users: rule.users, + }; + if (rule.checkPeriod) out.checkPeriod = rule.checkPeriod; + return out; +} + +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; + +// A tiny pretty-printer that keeps arrays of primitives — and short rule +// objects — on a single line, which is how Tailscale and Headscale policy +// examples are formatted. Keeping the output close to hand-written policies +// means the diff view only shows what actually changed. +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); + 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); +} + +// Renders an object on one line, but only when every value is a primitive or +// an array of primitives. Returns undefined when it must 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"; +} diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts index de7c9a17..32fb6db3 100644 --- a/app/utils/node-info.ts +++ b/app/utils/node-info.ts @@ -77,7 +77,7 @@ export function extractTagOwnerTags(policy: string | undefined): string[] { } } -function stripJsonCommentsAndTrailingCommas(input: string): string { +export function stripJsonCommentsAndTrailingCommas(input: string): string { let output = ""; let inString = false; let escaped = false; diff --git a/tests/unit/utils/acl-policy.test.ts b/tests/unit/utils/acl-policy.test.ts new file mode 100644 index 00000000..5e166777 --- /dev/null +++ b/tests/unit/utils/acl-policy.test.ts @@ -0,0 +1,264 @@ +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: {}, + }); + 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"] }]); + expect(policy.ssh).toEqual([ + { + action: "check", + src: ["group:ops"], + dst: ["tag:server"], + users: ["root"], + checkPeriod: "12h", + }, + ]); + }); + + 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"`); + }); +}); + +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: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("tag:web")).toBe(false); + expect(hasPortSpec("fd7a::1")).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); + }); +}); From 9b8e9ad31e1a38039061f690112477f2b7b261c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberindo=20Loffr=C3=A8?= Date: Tue, 18 Aug 2026 00:13:32 +0200 Subject: [PATCH 2/6] feat(ui): add a visual editor for ACL rules, tags and groups The Access Control page gains two tabs in front of the file editor: - Rules renders `acls`, `ssh` and `hosts` as editable lists. Sources and destinations are built from chips, suggested from the groups, tags, hosts and Headscale users that actually exist in the tailnet. A destination typed without a port gets `:*` appended. - Tags & Groups manages `groups` and `tagOwners`, showing which machines currently carry each tag. Both write into the same buffer the file editor uses, so the diff view and the save button keep working on a single source of truth and nothing reaches Headscale until Save is pressed. A policy that fails to parse falls back to a notice pointing at the file editor, and a policy with comments warns that the structured editors will drop them. Co-Authored-By: Claude Opus 5 (1M context) --- app/routes/acls/acl-loader.ts | 33 +- app/routes/acls/components/rules-editor.tsx | 287 ++++++++++++++++++ .../acls/components/tags-groups-editor.tsx | 251 +++++++++++++++ app/routes/acls/components/token-list.tsx | 142 +++++++++ app/routes/acls/dialogs/acl-rule.tsx | 92 ++++++ app/routes/acls/dialogs/host.tsx | 76 +++++ app/routes/acls/dialogs/named-list.tsx | 105 +++++++ app/routes/acls/dialogs/ssh-rule.tsx | 113 +++++++ app/routes/acls/overview.tsx | 104 ++++++- 9 files changed, 1198 insertions(+), 5 deletions(-) create mode 100644 app/routes/acls/components/rules-editor.tsx create mode 100644 app/routes/acls/components/tags-groups-editor.tsx create mode 100644 app/routes/acls/components/token-list.tsx create mode 100644 app/routes/acls/dialogs/acl-rule.tsx create mode 100644 app/routes/acls/dialogs/host.tsx create mode 100644 app/routes/acls/dialogs/named-list.tsx create mode 100644 app/routes/acls/dialogs/ssh-rule.tsx diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts index 63d4b850..ed5cf294 100644 --- a/app/routes/acls/acl-loader.ts +++ b/app/routes/acls/acl-loader.ts @@ -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,38 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) { access: auth.can(principal, Capabilities.write_policy), writable: false, policy: "", + // Context for the visual editor: which users exist and which tags are + // already in use. Both are best-effort, the editor degrades gracefully. + 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(); + 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) { + // The policy itself is what this page is about, so a failure to enrich it + // with users and tag usage is not fatal. + log.warn("api", "Failed to load ACL editor context: %s", String(error)); + } + try { const { policy, updatedAt } = await api.policy.get(); flags.writable = updatedAt !== null; diff --git a/app/routes/acls/components/rules-editor.tsx b/app/routes/acls/components/rules-editor.tsx new file mode 100644 index 00000000..bdaf81db --- /dev/null +++ b/app/routes/acls/components/rules-editor.tsx @@ -0,0 +1,287 @@ +import { ArrowRight, Pencil, Plus, Terminal, Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +import Button from "~/components/button"; +import Chip from "~/components/chip"; +import TableList from "~/components/table-list"; +import type { AclRule, Policy, SshRule } from "~/utils/acl-policy"; +import cn from "~/utils/cn"; + +import AclRuleDialog from "../dialogs/acl-rule"; +import HostDialog from "../dialogs/host"; +import SshRuleDialog from "../dialogs/ssh-rule"; + +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(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 ( +
+ {editing?.kind === "acl" ? ( + { + if (!open) setEditing(null); + }} + sources={sources} + /> + ) : null} + {editing?.kind === "ssh" ? ( + { + if (!open) setEditing(null); + }} + sources={sources} + /> + ) : null} + {editing?.kind === "host" ? ( + { + if (!open) setEditing(null); + }} + value={hostName ? policy.hosts[hostName] : undefined} + /> + ) : null} + +
setEditing({ kind: "acl", index: null })} + title="Access rules" + > + {policy.acls.length === 0 ? ( + + ) : ( + policy.acls.map((rule, index) => ( + +
+ Allow + + + + {rule.proto ? : null} +
+ + onChange({ ...policy, acls: policy.acls.filter((_, i) => i !== index) }) + } + onEdit={() => setEditing({ kind: "acl", index })} + /> +
+ )) + )} +
+ +
setEditing({ kind: "ssh", index: null })} + title="SSH rules" + > + {policy.ssh.length === 0 ? ( + + ) : ( + policy.ssh.map((rule, index) => ( + +
+ + {rule.action} + + + + as + +
+ + onChange({ ...policy, ssh: policy.ssh.filter((_, i) => i !== index) }) + } + onEdit={() => setEditing({ kind: "ssh", index })} + /> +
+ )) + )} +
+ +
setEditing({ kind: "host", name: null })} + title="Hosts" + > + {hostEntries.length === 0 ? ( + + ) : ( + hostEntries.map(([name, value]) => ( + +
+ {name} + {value} +
+ { + const hosts = { ...policy.hosts }; + delete hosts[name]; + onChange({ ...policy, hosts }); + }} + onEdit={() => setEditing({ kind: "host", name })} + /> +
+ )) + )} +
+
+ ); +} + +interface SectionProps { + title: string; + description: string; + isDisabled: boolean; + onAdd: () => void; + children: ReactNode; +} + +function Section({ title, description, isDisabled, onAdd, children }: SectionProps) { + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ {children} +
+ ); +} + +function ChipRow({ values }: { values: string[] }) { + return ( + + {values.map((value) => ( + + ))} + + ); +} + +function Empty({ text }: { text: string }) { + return ( + {text} + ); +} + +function RowActions({ + isDisabled, + onEdit, + onDelete, +}: { + isDisabled: boolean; + onEdit: () => void; + onDelete: () => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/app/routes/acls/components/tags-groups-editor.tsx b/app/routes/acls/components/tags-groups-editor.tsx new file mode 100644 index 00000000..6239203e --- /dev/null +++ b/app/routes/acls/components/tags-groups-editor.tsx @@ -0,0 +1,251 @@ +import { Pencil, Plus, Trash2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +import Button from "~/components/button"; +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"; + +export interface TagUsage { + tag: string; + nodes: string[]; +} + +interface TagsGroupsEditorProps { + policy: Policy; + onChange: (policy: Policy) => void; + isDisabled: boolean; + // Headscale usernames, used for member suggestions and usage counts. + 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(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 ( +
+ {editing ? ( + { + if (!open) setEditing(null); + }} + suggestions={editing.kind === "group" ? userSuggestions : ownerSuggestions} + /> + ) : null} + +
+ 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 ? ( + + ) : ( + groups.map(([name, members]) => ( + +
+ {name} + + {members.length === 0 ? ( + No members + ) : ( + members.map((member) => ( + + )) + )} + +
+ remove("group", name)} + onEdit={() => setEditing({ kind: "group", name })} + /> +
+ )) + )} +
+ +
+ 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{" "} + + Tailscale tag documentation + + . + + } + isDisabled={isDisabled} + onAdd={() => setEditing({ kind: "tag", name: null })} + title="Tags" + > + {tags.length === 0 ? ( + + ) : ( + tags.map(([name, owners]) => { + const usedBy = tagUsage.find((usage) => usage.tag === name)?.nodes ?? []; + return ( + +
+
+ {name} + + {usedBy.length === 0 + ? "Not assigned to any machine" + : `${usedBy.length} machine${usedBy.length === 1 ? "" : "s"}: ${usedBy.join(", ")}`} + +
+ + {owners.length === 0 ? ( + No owners + ) : ( + owners.map((owner) => ) + )} + +
+ remove("tag", name)} + onEdit={() => setEditing({ kind: "tag", name })} + /> +
+ ); + }) + )} +
+
+ ); +} + +interface SectionProps { + title: string; + description: ReactNode; + isDisabled: boolean; + onAdd: () => void; + children: ReactNode; +} + +function Section({ title, description, isDisabled, onAdd, children }: SectionProps) { + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ {children} +
+ ); +} + +function Empty({ text }: { text: string }) { + return {text}; +} + +function RowActions({ + isDisabled, + onEdit, + onDelete, +}: { + isDisabled: boolean; + onEdit: () => void; + onDelete: () => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/app/routes/acls/components/token-list.tsx b/app/routes/acls/components/token-list.tsx new file mode 100644 index 00000000..99729c69 --- /dev/null +++ b/app/routes/acls/components/token-list.tsx @@ -0,0 +1,142 @@ +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 used across the ACL dialogs. It mirrors the machine tag +// dialog: a list of the current values, a text field to add a new one, and a +// row of one-click suggestions pulled from the policy. +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 ( +
+
+

{label}

+ {description ? ( +

{description}

+ ) : null} +
+ + {values.length === 0 ? ( + + {emptyText} + + ) : ( + values.map((value) => ( + + {value} + + + )) + )} + +
+ 0 && draftIsInvalid} + label={label} + labelHidden + onChange={setDraft} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (!draftIsInvalid) add(draft); + } + }} + placeholder={placeholder} + value={draft} + /> + +
+ {available.length > 0 ? ( +
+ {available.map((suggestion) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/app/routes/acls/dialogs/acl-rule.tsx b/app/routes/acls/dialogs/acl-rule.tsx new file mode 100644 index 00000000..98478e60 --- /dev/null +++ b/app/routes/acls/dialogs/acl-rule.tsx @@ -0,0 +1,92 @@ +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 { withDefaultPort, type AclRule } from "~/utils/acl-policy"; + +import TokenList from "../components/token-list"; + +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: [] }; + +export default function AclRuleDialog({ + isOpen, + setIsOpen, + rule, + sources, + destinations, + onSave, +}: AclRuleDialogProps) { + const [draft, setDraft] = useState(rule ?? EMPTY); + + useEffect(() => { + if (isOpen) { + setDraft(rule ? structuredClone(rule) : structuredClone(EMPTY)); + } + }, [isOpen, rule]); + + const isInvalid = draft.src.length === 0 || draft.dst.length === 0; + + return ( + + { + event.preventDefault(); + // Destinations loaded from a hand-written policy may be missing their + // port spec, which Headscale rejects. Fix them up on the way out. + onSave({ ...draft, dst: draft.dst.map(withDefaultPort) }); + setIsOpen(false); + }} + > + {rule ? "Edit access rule" : "New access rule"} + + Access rules allow traffic from a set of sources to a set of destinations. Destinations + must include a port, for example tag:web:80,443. If you + leave the port out, :* is added for you. See the{" "} + + Tailscale ACL guide + {" "} + for the full syntax. + + setDraft({ ...draft, src })} + placeholder="group:eng" + suggestions={sources} + values={draft.src} + /> + setDraft({ ...draft, dst })} + placeholder="tag:web:80,443" + suggestions={destinations} + values={draft.dst} + /> + setDraft({ ...draft, proto: proto.length > 0 ? proto : undefined })} + placeholder="tcp" + value={draft.proto ?? ""} + /> + + + ); +} diff --git a/app/routes/acls/dialogs/host.tsx b/app/routes/acls/dialogs/host.tsx new file mode 100644 index 00000000..76a9bbb7 --- /dev/null +++ b/app/routes/acls/dialogs/host.tsx @@ -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 ( + + { + event.preventDefault(); + onSave(draftName, draftValue.trim()); + setIsOpen(false); + }} + > + {name ? `Edit host ${name}` : "New host"} + + Hosts give a name to an IP address or CIDR range so it can be referenced from rules. + + + 0 && valueIsInvalid} + label="Address" + onChange={setDraftValue} + placeholder="100.64.0.0/24" + value={draftValue} + /> + + + ); +} diff --git a/app/routes/acls/dialogs/named-list.tsx b/app/routes/acls/dialogs/named-list.tsx new file mode 100644 index 00000000..d5f7a921 --- /dev/null +++ b/app/routes/acls/dialogs/named-list.tsx @@ -0,0 +1,105 @@ +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 { isValidGroupName, isValidTagName } from "~/utils/acl-policy"; + +import TokenList from "../components/token-list"; + +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[]; + // Names already used, so we can reject duplicates. + 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(members ?? []); + + useEffect(() => { + if (isOpen) { + setDraftName(name ?? copy.prefix); + setDraftMembers(members ? [...members] : []); + } + }, [isOpen, name, members, copy.prefix]); + + const isDuplicate = draftName !== name && existingNames.includes(draftName); + const nameIsInvalid = !copy.validate(draftName) || isDuplicate; + + return ( + + { + event.preventDefault(); + onSave(draftName, draftMembers); + setIsOpen(false); + }} + > + {name ? `Edit ${copy.title} ${name}` : `New ${copy.title}`} + {copy.hint} + + + + + ); +} diff --git a/app/routes/acls/dialogs/ssh-rule.tsx b/app/routes/acls/dialogs/ssh-rule.tsx new file mode 100644 index 00000000..e9a2796b --- /dev/null +++ b/app/routes/acls/dialogs/ssh-rule.tsx @@ -0,0 +1,113 @@ +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 type { SshRule } from "~/utils/acl-policy"; + +import TokenList from "../components/token-list"; + +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: [] }; +const SSH_USERS = ["root", "autogroup:nonroot"]; + +export default function SshRuleDialog({ + isOpen, + setIsOpen, + rule, + sources, + destinations, + onSave, +}: SshRuleDialogProps) { + const [draft, setDraft] = useState(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 ( + + { + event.preventDefault(); + onSave(draft); + setIsOpen(false); + }} + > + {rule ? "Edit SSH rule" : "New SSH rule"} + + SSH rules control Tailscale SSH access between nodes. Read the{" "} + + Tailscale SSH documentation + {" "} + for details about check mode. + + + setDraft({ ...draft, checkPeriod: checkPeriod.length > 0 ? checkPeriod : undefined }) + } + placeholder="12h" + value={draft.checkPeriod ?? ""} + /> + ) : null} + + + ); +} diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx index 51206f9f..ace3ce9b 100644 --- a/app/routes/acls/overview.tsx +++ b/app/routes/acls/overview.tsx @@ -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(); 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,38 @@ export default function Page({ loaderData: { access, writable, policy } }: Route } }, [fetcher.data]); + // The structured editors round-trip through the policy text so that the + // file editor, the diff view and the save button all keep working on a + // single source of truth. + function applyPolicy(next: Policy) { + setCodePolicy(serializePolicy(next)); + } + + function structuredPanel(render: (value: Policy) => ReactNode) { + if (!parsed.ok) { + return ( +
+ + The policy could not be parsed ({parsed.error}). Fix it in the Edit file{" "} + tab and the visual editor will come back. + +
+ ); + } + + return ( +
+ {parsed.hasComments ? ( + + This policy contains comments. Saving a change made in the visual editor rewrites the + policy and drops them. + + ) : null} + {render(parsed.policy)} +
+ ); + } + return (
{!access ? ( @@ -86,8 +148,20 @@ export default function Page({ loaderData: { access, writable, policy } }: Route "An unknown error occurred while trying to update the ACL policy."} ) : undefined} - + + +
+ + Rules +
+
+ +
+ + Tags & Groups +
+
@@ -107,6 +181,28 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
+ + {structuredPanel((value) => ( + + ))} + + + {structuredPanel((value) => ( + + ))} + }> From 93d0b13cdddec50db9031eb374a0ee514b89b539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberindo=20Loffr=C3=A8?= Date: Tue, 18 Aug 2026 00:13:47 +0200 Subject: [PATCH 3/6] feat(ui): manage ACL groups from the users page Group membership is stored in the ACL policy, which made it invisible from the page where users are actually managed. Show the groups a user belongs to under their name, and add an "Edit groups" entry to the row menu that rewrites the `groups` section of the policy. Editing requires `write_policy` on top of `write_users`, and the loader treats the policy as optional: an unreadable one just hides the UI instead of breaking the page. Co-Authored-By: Claude Opus 5 (1M context) --- .../users/components/headplane-user-row.tsx | 14 ++ .../users/components/headscale-user-menu.tsx | 26 ++- .../users/components/headscale-user-row.tsx | 27 ++- app/routes/users/components/menu.tsx | 22 ++- app/routes/users/dialogs/user-groups.tsx | 166 ++++++++++++++++++ app/routes/users/overview.tsx | 36 +++- app/routes/users/user-actions.ts | 32 ++++ 7 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 app/routes/users/dialogs/user-groups.tsx diff --git a/app/routes/users/components/headplane-user-row.tsx b/app/routes/users/components/headplane-user-row.tsx index 0d3cf6ce..35875c72 100644 --- a/app/routes/users/components/headplane-user-row.tsx +++ b/app/routes/users/components/headplane-user-row.tsx @@ -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,8 @@ interface HeadplaneUserRowProps { headscaleUsers: { id: string; name: string; claimed: boolean }[]; isSelf?: boolean; isOwner?: boolean; + canEditGroups?: boolean; + policyGroups?: string[]; } export default function HeadplaneUserRow({ @@ -19,6 +22,8 @@ export default function HeadplaneUserRow({ headscaleUsers, isSelf, isOwner, + canEditGroups, + policyGroups, }: HeadplaneUserRowProps) { const isOnline = user.machines.some((machine) => machine.online); const lastSeen = user.machines.reduce( @@ -50,6 +55,13 @@ export default function HeadplaneUserRow({ {!user.headscaleUserId && (

Not linked

)} + {user.groups.length > 0 && ( +
+ {user.groups.map((group) => ( + + ))} +
+ )}
@@ -77,10 +89,12 @@ export default function HeadplaneUserRow({ diff --git a/app/routes/users/components/headscale-user-menu.tsx b/app/routes/users/components/headscale-user-menu.tsx index 5012f742..39215765 100644 --- a/app/routes/users/components/headscale-user-menu.tsx +++ b/app/routes/users/components/headscale-user-menu.tsx @@ -5,15 +5,22 @@ 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[]; } -type Modal = "rename" | "delete" | null; +type Modal = "rename" | "groups" | "delete" | null; -export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) { +export default function HeadscaleUserMenu({ + user, + canEditGroups, + policyGroups, +}: HeadscaleUserMenuProps) { const [modal, setModal] = useState(null); // Headscale-managed OIDC users cannot be renamed via the API. @@ -30,6 +37,18 @@ export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) { user={user} /> )} + {modal === "groups" && canEditGroups && ( + { + if (!isOpen) setModal(null); + }} + userName={user.name} + /> + )} {modal === "delete" && ( {canRename && setModal("rename")}>Rename} - {canRename && } + {canEditGroups && setModal("groups")}>Edit groups} + {(canRename || canEditGroups) && } setModal("delete")}> Delete diff --git a/app/routes/users/components/headscale-user-row.tsx b/app/routes/users/components/headscale-user-row.tsx index 699e7ca6..340c99d9 100644 --- a/app/routes/users/components/headscale-user-row.tsx +++ b/app/routes/users/components/headscale-user-row.tsx @@ -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,16 @@ import HeadscaleUserMenu from "./headscale-user-menu"; interface HeadscaleUserRowProps { user: UnlinkedHeadscaleUser; writable?: boolean; + canEditGroups?: boolean; + policyGroups?: string[]; } -export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) { +export default function HeadscaleUserRow({ + user, + writable, + canEditGroups, + policyGroups, +}: 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 +42,13 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro

{displayName}

{displayUsername &&

{displayUsername}

} {user.email &&

{user.email}

} + {user.groups.length > 0 && ( +
+ {user.groups.map((group) => ( + + ))} +
+ )} @@ -56,7 +71,15 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro

No machines

)} - {writable ? : null} + + {writable ? ( + + ) : null} + ); } diff --git a/app/routes/users/components/menu.tsx b/app/routes/users/components/menu.tsx index f75963bc..e388e26a 100644 --- a/app/routes/users/components/menu.tsx +++ b/app/routes/users/components/menu.tsx @@ -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,11 @@ interface MenuProps { currentLink?: string; isSelf?: boolean; isOwner?: boolean; + canEditGroups?: boolean; + policyGroups?: string[]; } -type Modal = "delete" | "reassign" | "link" | "transfer" | null; +type Modal = "delete" | "reassign" | "link" | "transfer" | "groups" | null; export default function UserMenu({ user, @@ -25,6 +28,8 @@ export default function UserMenu({ currentLink, isSelf, isOwner, + canEditGroups, + policyGroups, }: MenuProps) { const [modal, setModal] = useState(null); @@ -74,6 +79,18 @@ export default function UserMenu({ }} /> )} + {modal === "groups" && user.linkedHeadscaleUser && ( + { + if (!isOpen) setModal(null); + }} + userName={user.linkedHeadscaleUser.name} + /> + )} {modal === "transfer" && ( setModal("link")}> {isLinked ? "Change linked user" : "Link Headscale user"} + {canEditGroups && user.linkedHeadscaleUser && ( + setModal("groups")}>Edit groups + )} {isOwner && !isSelf && ( <> diff --git a/app/routes/users/dialogs/user-groups.tsx b/app/routes/users/dialogs/user-groups.tsx new file mode 100644 index 00000000..aa976e2b --- /dev/null +++ b/app/routes/users/dialogs/user-groups.tsx @@ -0,0 +1,166 @@ +import { Plus, UsersRound, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useFetcher } from "react-router"; + +import Button from "~/components/button"; +import Dialog, { DialogPanel } from "~/components/dialog"; +import Input from "~/components/input"; +import Link from "~/components/link"; +import TableList from "~/components/table-list"; +import Text from "~/components/text"; +import Title from "~/components/title"; +import { isValidGroupName } from "~/utils/acl-policy"; +import cn from "~/utils/cn"; + +interface UserGroupsProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + // The Headscale username, which is what the ACL policy references. + userName: string; + displayName: string; + groups: string[]; + // Every group defined in the policy, for one-click assignment. + availableGroups: string[]; +} + +export default function UserGroups({ + isOpen, + setIsOpen, + userName, + displayName, + groups, + availableGroups, +}: UserGroupsProps) { + const fetcher = useFetcher<{ message?: string; error?: string }>(); + const submittingRef = useRef(false); + const [selected, setSelected] = useState([...groups]); + const [draft, setDraft] = useState("group:"); + + const options = useMemo( + () => availableGroups.filter((group) => !selected.includes(group)), + [availableGroups, selected], + ); + + const draftIsInvalid = useMemo( + () => !isValidGroupName(draft) || selected.includes(draft), + [draft, selected], + ); + + const error = fetcher.data?.error; + + useEffect(() => { + if (isOpen) { + setSelected([...groups]); + setDraft("group:"); + } + }, [isOpen, groups]); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data) { + submittingRef.current = false; + if (!fetcher.data.error) { + setIsOpen(false); + } + } + }, [fetcher.data, fetcher.state]); + + return ( + { + if (!open && submittingRef.current) { + return; + } + setIsOpen(open); + }} + > + { + 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" }); + }} + > + Edit ACL groups for {displayName} + + Groups live in the ACL policy, not in Headscale. Changing them here rewrites the{" "} + groups section of your policy. See the{" "} + + Tailscale ACL guide + {" "} + for details. + + {error ? ( +

+ {error} +

+ ) : null} + + {selected.length === 0 ? ( + + +

This user is not in any group

+
+ ) : ( + selected.map((group) => ( + + {group} + + + )) + )} +
+ +
+ 0 && draftIsInvalid} + label="Group" + labelHidden + onChange={setDraft} + placeholder="group:example" + value={draft} + /> + +
+ {options.length > 0 ? ( +
+ {options.map((group) => ( + + ))} +
+ ) : null} +
+
+ ); +} diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 04b3df50..5630e4b4 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -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,14 @@ export interface HeadplaneUserData { linkedHeadscaleUser?: User; machines: Machine[]; profilePicUrl?: string; + // ACL groups the linked Headscale user belongs to + groups: string[]; } export interface UnlinkedHeadscaleUser extends User { machines: Machine[]; + // ACL groups this user belongs to + groups: string[]; } export async function loader({ request, context }: Route.LoaderArgs) { @@ -66,6 +71,8 @@ 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(); try { const { api } = await getRequestApi(request); @@ -75,6 +82,21 @@ export async function loader({ request, context }: Route.LoaderArgs) { ]); nodes = nodesSnap.data; apiUsers = usersSnap.data; + + // ACL groups are stored in the policy, so they are fetched separately and + // treated as optional: a missing or unreadable policy just hides the UI. + try { + const { policy } = await api.policy.get(); + const parsed = parsePolicy(policy); + if (parsed.ok) { + 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 +139,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 +152,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 +168,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { return { writable: writablePermission, + canEditGroups: writablePermission && auth.can(principal, Capabilities.write_policy), + policyGroups, currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined, isOwner, oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined, @@ -204,10 +230,12 @@ export default function Page({ loaderData }: Route.ComponentProps) { > {loaderData.headplaneUsers.map((user) => ( ))} @@ -243,7 +271,13 @@ export default function Page({ loaderData }: Route.ComponentProps) { )} > {loaderData.unlinkedHeadscaleUsers.map((user) => ( - + ))} diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 1978951c..be490ceb 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -5,6 +5,7 @@ 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 type { Route } from "./+types/overview"; @@ -131,6 +132,37 @@ export async function userAction({ request, context }: Route.ActionArgs) { return { message: "Headscale user linked successfully" }; } + case "update_user_groups": { + // Group membership lives in the ACL policy, so this needs the policy + // capability on top of the user one checked above. + 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); + } + + await api.policy.set(serializePolicy(setUserGroups(parsed.policy, userName, groups))); + return { message: "Groups updated successfully" }; + } default: throw data("Invalid `action_id` provided.", { status: 400, From f33e4ba62596a14ff32a441b6e47c436f902db3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberindo=20Loffr=C3=A8?= Date: Tue, 18 Aug 2026 00:13:48 +0200 Subject: [PATCH 4/6] feat(ui): flag machine tags that are not declared in the policy Headscale accepts any forced tag on a node, but a tag that is missing from `tagOwners` will never match a rule, which is easy to miss. Mark those tags in the machine tag dialog and point at the Access Control page where they can be declared. Co-Authored-By: Claude Opus 5 (1M context) --- .../machines/components/machine-row.tsx | 3 ++ app/routes/machines/components/menu.tsx | 3 ++ app/routes/machines/dialogs/tags.tsx | 30 +++++++++++++++++-- app/routes/machines/machine.tsx | 5 +++- app/routes/machines/overview.tsx | 9 +++++- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 0d764513..de976ae7 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -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({ { diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index 012afbe0..e7f7405b 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -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,12 @@ interface TagsProps { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; existingTags?: string[]; + // Tags declared under `tagOwners` in the ACL policy. Anything outside this + // list is assignable but will not match any 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 +35,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 +103,12 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP ) : ( tags.map((item) => ( - {item} + + {item} + {undeclaredTags.includes(item) ? ( + + ) : null} +