diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index 6affc80..396cd42 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -24,6 +24,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP const submittingRef = useRef(false); const [tags, setTags] = useState([...machine.tags]); const [tag, setTag] = useState("tag:"); + const tagOptions = useMemo( + () => (existingTags ?? []).filter((existingTag) => !tags.includes(existingTag)), + [existingTags, tags], + ); const tagIsInvalid = useMemo( () => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag), [tag, tags], @@ -128,6 +132,21 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP + {tagOptions.length > 0 ? ( +
+ {tagOptions.map((option) => ( + + ))} +
+ ) : null}

Not seeing the tags you expect? Tags need to be defined in your access control policy before they can be assigned to machines. diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index bc228e0..624adc8 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -122,6 +122,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { { success: false as const, error: + extractApiErrorMessage(error.data) ?? "One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.", }, { status: 400 }, @@ -207,3 +208,14 @@ export async function machineAction({ request, context }: Route.ActionArgs) { }); } } + +function extractApiErrorMessage(error: { data?: unknown; rawData: string }) { + if (error.data != null && typeof error.data === "object" && "message" in error.data) { + const message = (error.data as { message?: unknown }).message; + if (typeof message === "string" && message.length > 0) { + return message; + } + } + + return error.rawData.length > 0 ? error.rawData : undefined; +} diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index fb169a0..fbbfaac 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -12,7 +12,7 @@ import Tooltip from "~/components/tooltip"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; import cn from "~/utils/cn"; import { getOSInfo, getTSVersion } from "~/utils/host-info"; -import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info"; +import { isNoExpiry, mapNodes, sortAssignableTags } from "~/utils/node-info"; import { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/machine"; @@ -50,11 +50,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { } const agents = context.agents.state === "enabled" ? context.agents.value : undefined; - const lookup = await agents?.lookup([node.nodeKey]); - const [enhancedNode] = mapNodes([node], lookup); + const [lookup, policyResult] = await Promise.allSettled([ + agents?.lookup([node.nodeKey]), + api.policy.get(), + ]); + const stats = lookup.status === "fulfilled" ? lookup.value : undefined; + const [enhancedNode] = mapNodes([node], stats); const tags = [...node.tags].toSorted(); const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const agentSync = agents?.lastSync(); + const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; return { agent: agentSync @@ -64,10 +69,10 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { nodeKey: agents?.agentNodeKey(), } : undefined, - existingTags: sortNodeTags(nodes), + existingTags: sortAssignableTags(nodes, policy), magic, node: enhancedNode, - stats: lookup?.[enhancedNode.nodeKey], + stats: stats?.[enhancedNode.nodeKey], supportsNodeOwnerChange: supportsNodeOwnerChange, tags, users, diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 72f5b96..70e72a0 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -10,7 +10,7 @@ import Tooltip from "~/components/tooltip"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; -import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info"; +import { mapNodes, sortAssignableTags, type PopulatedNode } from "~/utils/node-info"; import type { Route } from "./+types/overview"; import { MachineFilters } from "./components/machine-filters"; @@ -46,7 +46,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { } const agents = context.agents.state === "enabled" ? context.agents.value : undefined; - const stats = await agents?.lookup(nodes.map((node) => node.nodeKey)); + const [statsResult, policyResult] = await Promise.allSettled([ + agents?.lookup(nodes.map((node) => node.nodeKey)), + api.policy.get(), + ]); + const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined; + const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; const populatedNodes = mapNodes(nodes, stats); const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const agentSync = agents?.lastSync(); @@ -60,6 +65,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { } : undefined, headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined, + existingTags: sortAssignableTags(nodes, policy), magic, nodes, populatedNodes, @@ -423,7 +429,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { ) : ( filteredAndSortedNodes.map((node) => ( node.tags))).sort(); } + +export function sortAssignableTags(nodes: Machine[], policy?: string): string[] { + return Array.from(new Set([...sortNodeTags(nodes), ...extractTagOwnerTags(policy)])).sort(); +} + +export function extractTagOwnerTags(policy: string | undefined): string[] { + if (!policy) { + return []; + } + + 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(); + } catch { + return []; + } +} + +function stripJsonCommentsAndTrailingCommas(input: string): string { + let output = ""; + let inString = false; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + + for (let i = 0; i < input.length; i++) { + const char = input[i]; + const next = input[i + 1]; + + if (inLineComment) { + if (char === "\n" || char === "\r") { + inLineComment = false; + output += char; + } + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + i++; + } + continue; + } + + if (inString) { + output += char; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === "/" && next === "/") { + inLineComment = true; + i++; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + i++; + continue; + } + + output += char; + } + + return output.replace(/,\s*([}\]])/g, "$1"); +} diff --git a/tests/unit/utils/node-info.test.ts b/tests/unit/utils/node-info.test.ts index e8e3cb0..36ecb10 100644 --- a/tests/unit/utils/node-info.test.ts +++ b/tests/unit/utils/node-info.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { isNoExpiry } from "~/utils/node-info"; +import { extractTagOwnerTags, isNoExpiry, sortAssignableTags } from "~/utils/node-info"; describe("isNoExpiry", () => { test("returns true for null", () => { @@ -27,3 +27,51 @@ describe("isNoExpiry", () => { expect(isNoExpiry("2020-01-01T00:00:00Z")).toBe(false); }); }); + +describe("extractTagOwnerTags", () => { + test("reads tags from HuJSON tagOwners", () => { + expect( + extractTagOwnerTags(`{ + // comment + "tagOwners": { + "tag:prod": ["group:admins"], + "tag:server": [], + }, + }`), + ).toEqual(["tag:prod", "tag:server"]); + }); + + test("ignores invalid policies", () => { + expect(extractTagOwnerTags("not-json")).toEqual([]); + }); +}); + +describe("sortAssignableTags", () => { + test("unions assigned node tags with ACL tag owners", () => { + expect( + sortAssignableTags( + [ + { + id: "1", + givenName: "node", + name: "node", + machineKey: "mkey:test", + nodeKey: "nodekey:test", + discoKey: "discokey:test", + ipAddresses: [], + tags: ["tag:used"], + lastSeen: "", + expiry: null, + online: true, + registerMethod: "REGISTER_METHOD_AUTH_KEY", + createdAt: "", + availableRoutes: [], + approvedRoutes: [], + subnetRoutes: [], + }, + ], + '{"tagOwners":{"tag:declared":[]}}', + ), + ).toEqual(["tag:declared", "tag:used"]); + }); +});