feat: official 0.28 support possibly

This commit is contained in:
Aarnav Tale
2026-02-06 00:11:26 -05:00
parent d2c16d2380
commit fad0c99fc9
6 changed files with 218 additions and 190 deletions
+1 -2
View File
@@ -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"]
}
+1 -28
View File
@@ -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",
+51 -6
View File
@@ -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 (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
setIsOpen(open);
}}
>
<Dialog.Panel
onSubmit={(event) => {
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"}
>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
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
</Link>{" "}
for more information.
</Dialog.Text>
<input name="action_id" type="hidden" value="update_tags" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="tags" type="hidden" value={tags.join(",")} />
{error ? (
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</p>
) : null}
<TableList className="mt-4">
{tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
@@ -90,6 +131,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
<Plus className="p-1" size={30} />
</Button>
</div>
<p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
</p>
</Dialog.Panel>
</Dialog>
);
+163 -150
View File
@@ -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,
});
}
}
+1 -2
View File
@@ -19,7 +19,7 @@ interface RawMachine extends Omit<Machine, "tags"> {
* @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<NodeEndpoints>((client, apiKey) => ({
getNodes: async () => {
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
return nodes.map((node) => normalizeTags(client, node));
},
+1 -2
View File
@@ -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) {