From fad0c99fc92ddd00b7b982493ad90a4361eb6fb1 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 6 Feb 2026 00:11:26 -0500 Subject: [PATCH] feat: official 0.28 support possibly --- app/openapi-canonical-families.json | 3 +- app/openapi-operation-hashes.json | 29 +- app/routes/machines/dialogs/tags.tsx | 57 +++- app/routes/machines/machine-actions.ts | 313 ++++++++++---------- app/server/headscale/api/endpoints/nodes.ts | 3 +- tests/generate-openapi-hashes.ts | 3 +- 6 files changed, 218 insertions(+), 190 deletions(-) diff --git a/app/openapi-canonical-families.json b/app/openapi-canonical-families.json index ed10aa6..6fbad5a 100644 --- a/app/openapi-canonical-families.json +++ b/app/openapi-canonical-families.json @@ -2,6 +2,5 @@ "0.26.1": ["0.26.0", "0.26.1"], "0.27.0": ["0.27.0"], "0.27.1": ["0.27.1"], - "0.28.0-beta.1": ["0.28.0-beta.1"], - "0.28.0-beta.2": ["0.28.0-beta.2"] + "0.28.0": ["0.28.0"] } diff --git a/app/openapi-operation-hashes.json b/app/openapi-operation-hashes.json index 6500405..b8eba8b 100644 --- a/app/openapi-operation-hashes.json +++ b/app/openapi-operation-hashes.json @@ -105,34 +105,7 @@ "DELETE /api/v1/user/{id}": "3d553e4b74296884", "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" }, - "0.28.0-beta.1": { - "GET /api/v1/apikey": "efe31b6dc980e158", - "POST /api/v1/apikey": "39953a96c1da5312", - "POST /api/v1/apikey/expire": "ca56add866802f17", - "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", - "POST /api/v1/debug/node": "204f9ae3f9f738c6", - "GET /api/v1/health": "5e447272e72b2e5f", - "GET /api/v1/node": "8bb18b8c7cfb4f20", - "POST /api/v1/node/backfillips": "6da4d1d3922a8001", - "POST /api/v1/node/register": "539f7cb3a84d43d4", - "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", - "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", - "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", - "POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae", - "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", - "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", - "GET /api/v1/policy": "d6c639be304cd3c0", - "PUT /api/v1/policy": "6cbe80bde771a388", - "GET /api/v1/preauthkey": "14db6a04f90d7a7e", - "DELETE /api/v1/preauthkey": "fa2975a185782e5d", - "POST /api/v1/preauthkey": "0b4308e049d4eb58", - "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", - "GET /api/v1/user": "228831b58ccc5a17", - "POST /api/v1/user": "a4e1d889d7962da5", - "DELETE /api/v1/user/{id}": "3d553e4b74296884", - "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" - }, - "0.28.0-beta.2": { + "0.28.0": { "GET /api/v1/apikey": "efe31b6dc980e158", "POST /api/v1/apikey": "39953a96c1da5312", "POST /api/v1/apikey/expire": "ca56add866802f17", diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index 24aba81..ce53f41 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -1,5 +1,6 @@ import { Plus, TagsIcon, X } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useFetcher } from "react-router"; import type { Machine } from "~/types"; @@ -18,6 +19,8 @@ interface TagsProps { } export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) { + const fetcher = useFetcher(); + const submittingRef = useRef(false); const [tags, setTags] = useState([...machine.tags]); const [tag, setTag] = useState("tag:"); const tagIsInvalid = useMemo(() => { @@ -28,9 +31,45 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || []; }, [tags]); + const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null; + + useEffect(() => { + if (fetcher.data?.success) { + submittingRef.current = false; + setIsOpen(false); + } + + if (fetcher.state === "idle" && fetcher.data && !fetcher.data.success) { + submittingRef.current = false; + } + }, [fetcher.data, fetcher.state]); + + useEffect(() => { + if (isOpen) { + setTags([...machine.tags]); + } + }, [isOpen]); + return ( - - + { + if (!open && submittingRef.current) return; + setIsOpen(open); + }} + > + { + event.preventDefault(); + submittingRef.current = true; + const form = new FormData(); + form.set("action_id", "update_tags"); + form.set("node_id", machine.id); + form.set("tags", tags.filter((t) => t !== "").join(",")); + fetcher.submit(form, { method: "POST" }); + }} + isDisabled={fetcher.state !== "idle"} + > Edit ACL tags for {machine.givenName} ACL tags can be used to reference machines in your ACL policies. See the{" "} @@ -39,9 +78,11 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP {" "} for more information. - - - + {error ? ( +

+ {error} +

+ ) : null} {tags.length === 0 ? ( @@ -90,6 +131,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP +

+ 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 afa2e6f..831d57c 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -1,178 +1,191 @@ -import { data, redirect } from 'react-router'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/machine'; +import { data, redirect } from "react-router"; + +import { isDataWithApiError } from "~/server/headscale/api/error-client"; +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/machine"; export async function machineAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check( - request, - Capabilities.write_machines, - ); + const session = await context.sessions.auth(request); + const check = await context.sessions.check(request, Capabilities.write_machines); - const formData = await request.formData(); - const api = context.hsApi.getRuntimeClient(session.api_key); + const formData = await request.formData(); + const api = context.hsApi.getRuntimeClient(session.api_key); - const action = formData.get('action_id')?.toString(); - if (!action) { - throw data('Missing `action_id` in the form data.', { - status: 400, - }); - } + const action = formData.get("action_id")?.toString(); + if (!action) { + throw data("Missing `action_id` in the form data.", { + status: 400, + }); + } - // Fast track register since it doesn't require an existing machine - if (action === 'register') { - if (!check) { - throw data('You do not have permission to manage machines', { - status: 403, - }); - } + // Fast track register since it doesn't require an existing machine + if (action === "register") { + if (!check) { + throw data("You do not have permission to manage machines", { + status: 403, + }); + } - const registrationKey = formData.get('register_key')?.toString(); - if (!registrationKey) { - throw data('Missing `register_key` in the form data.', { - status: 400, - }); - } + const registrationKey = formData.get("register_key")?.toString(); + if (!registrationKey) { + throw data("Missing `register_key` in the form data.", { + status: 400, + }); + } - const user = formData.get('user')?.toString(); - if (!user) { - throw data('Missing `user` in the form data.', { - status: 400, - }); - } + const user = formData.get("user")?.toString(); + if (!user) { + throw data("Missing `user` in the form data.", { + status: 400, + }); + } - const node = await api.registerNode(user, registrationKey); - return redirect(`/machines/${node.id}`); - } + const node = await api.registerNode(user, registrationKey); + return redirect(`/machines/${node.id}`); + } - // Check if the user has permission to manage this machine - const nodeId = formData.get('node_id')?.toString(); - if (!nodeId) { - throw data('Missing `node_id` in the form data.', { - status: 400, - }); - } + // Check if the user has permission to manage this machine + const nodeId = formData.get("node_id")?.toString(); + if (!nodeId) { + throw data("Missing `node_id` in the form data.", { + status: 400, + }); + } - const node = await api.getNode(nodeId); - if (!node) { - throw data(`Machine with ID ${nodeId} not found`, { - status: 404, - }); - } + const node = await api.getNode(nodeId); + if (!node) { + throw data(`Machine with ID ${nodeId} not found`, { + status: 404, + }); + } - if ( - node.user.providerId?.split('/').pop() !== session.user.subject && - !check - ) { - throw data('You do not have permission to act on this machine', { - status: 403, - }); - } + if (node.user.providerId?.split("/").pop() !== session.user.subject && !check) { + throw data("You do not have permission to act on this machine", { + status: 403, + }); + } - switch (action) { - case 'rename': { - const newName = formData.get('name')?.toString(); - if (!newName) { - throw data('Missing `name` in the form data.', { - status: 400, - }); - } + switch (action) { + case "rename": { + const newName = formData.get("name")?.toString(); + if (!newName) { + throw data("Missing `name` in the form data.", { + status: 400, + }); + } - const name = String(formData.get('name')); - await api.renameNode(nodeId, name); - return { message: 'Machine renamed' }; - } + const name = String(formData.get("name")); + await api.renameNode(nodeId, name); + return { message: "Machine renamed" }; + } - case 'delete': { - await api.deleteNode(nodeId); - return redirect('/machines'); - } + case "delete": { + await api.deleteNode(nodeId); + return redirect("/machines"); + } - case 'expire': { - await api.expireNode(nodeId); - return { message: 'Machine expired' }; - } + case "expire": { + await api.expireNode(nodeId); + return { message: "Machine expired" }; + } - case 'update_tags': { - const tags = formData.get('tags')?.toString().split(',') ?? []; - if (tags.length === 0) { - throw data('Missing `tags` in the form data.', { - status: 400, - }); - } + case "update_tags": { + const tags = formData.get("tags")?.toString().split(",") ?? []; + if (tags.length === 0) { + throw data("Missing `tags` in the form data.", { + status: 400, + }); + } - await api.setNodeTags( - nodeId, - tags.map((tag) => tag.trim()).filter((tag) => tag !== ''), - ); - return { message: 'Tags updated' }; - } + try { + await api.setNodeTags( + nodeId, + tags.map((tag) => tag.trim()).filter((tag) => tag !== ""), + ); - case 'update_routes': { - const newApproved = node.approvedRoutes; - const routes = formData.get('routes')?.toString(); - if (!routes) { - throw data('Missing `routes` in the form data.', { - status: 400, - }); - } + return { success: true as const, message: "Tags updated" }; + } catch (error) { + if (isDataWithApiError(error) && error.data.statusCode === 400) { + return data( + { + success: false as const, + error: + "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 }, + ); + } - const allRoutes = routes.split(',').map((route) => route.trim()); - if (allRoutes.length === 0) { - throw data('No routes provided to update', { - status: 400, - }); - } + throw error; + } + } - const enabled = formData.get('enabled')?.toString(); - if (enabled === undefined) { - throw data('Missing `enabled` in the form data.', { - status: 400, - }); - } + case "update_routes": { + const newApproved = node.approvedRoutes; + const routes = formData.get("routes")?.toString(); + if (!routes) { + throw data("Missing `routes` in the form data.", { + status: 400, + }); + } - if (enabled === 'true') { - for (const route of allRoutes) { - // If already approved, skip, otherwise add to approved - if (newApproved.includes(route)) { - continue; - } + const allRoutes = routes.split(",").map((route) => route.trim()); + if (allRoutes.length === 0) { + throw data("No routes provided to update", { + status: 400, + }); + } - newApproved.push(route); - } - } else { - for (const route of allRoutes) { - // If not approved, skip, otherwise remove from approved - if (!newApproved.includes(route)) { - continue; - } + const enabled = formData.get("enabled")?.toString(); + if (enabled === undefined) { + throw data("Missing `enabled` in the form data.", { + status: 400, + }); + } - const index = newApproved.indexOf(route); - if (index > -1) { - newApproved.splice(index, 1); - } - } - } + if (enabled === "true") { + for (const route of allRoutes) { + // If already approved, skip, otherwise add to approved + if (newApproved.includes(route)) { + continue; + } - await api.approveNodeRoutes(nodeId, newApproved); - return { message: 'Routes updated' }; - } + newApproved.push(route); + } + } else { + for (const route of allRoutes) { + // If not approved, skip, otherwise remove from approved + if (!newApproved.includes(route)) { + continue; + } - case 'reassign': { - const user = formData.get('user_id')?.toString(); - if (!user) { - throw data('Missing `user_id` in the form data.', { - status: 400, - }); - } + const index = newApproved.indexOf(route); + if (index > -1) { + newApproved.splice(index, 1); + } + } + } - await api.setNodeUser(nodeId, user); - return { message: 'Machine reassigned' }; - } + await api.approveNodeRoutes(nodeId, newApproved); + return { message: "Routes updated" }; + } - default: - throw data('Invalid action', { - status: 400, - }); - } + case "reassign": { + const user = formData.get("user_id")?.toString(); + if (!user) { + throw data("Missing `user_id` in the form data.", { + status: 400, + }); + } + + await api.setNodeUser(nodeId, user); + return { message: "Machine reassigned" }; + } + + default: + throw data("Invalid action", { + status: 400, + }); + } } diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts index fe6c03e..9312e4c 100644 --- a/app/server/headscale/api/endpoints/nodes.ts +++ b/app/server/headscale/api/endpoints/nodes.ts @@ -19,7 +19,7 @@ interface RawMachine extends Omit { * @returns A Machine object with normalized tags. */ function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine { - if (client.isAtleast("0.28.0-beta.1")) { + if (client.isAtleast("0.28.0")) { return { ...node, tags: node.tags ?? [] } as Machine; } @@ -103,7 +103,6 @@ export interface NodeEndpoints { export default defineApiEndpoints((client, apiKey) => ({ getNodes: async () => { const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey); - return nodes.map((node) => normalizeTags(client, node)); }, diff --git a/tests/generate-openapi-hashes.ts b/tests/generate-openapi-hashes.ts index a04c31e..9db92a2 100644 --- a/tests/generate-openapi-hashes.ts +++ b/tests/generate-openapi-hashes.ts @@ -17,8 +17,7 @@ const SPEC_MAP = { "0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json", "0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json", "0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json", - "0.28.0-beta.1": "/v0.28.0-beta.1/gen/openapiv2/headscale/v1/headscale.swagger.json", - "0.28.0-beta.2": "/v0.28.0-beta.2/gen/openapiv2/headscale/v1/headscale.swagger.json", + "0.28.0": "/v0.28.0/gen/openapiv2/headscale/v1/headscale.swagger.json", } as const; async function hashOpenApiOperations(specUrl: string) {