From c700a041f2a25e187d44e14b2cbc372d854a62e4 Mon Sep 17 00:00:00 2001 From: Julian Lehrhuber Date: Thu, 7 May 2026 19:56:47 +0000 Subject: [PATCH] Add key expiry disable/enable toggle --- app/routes/machines/components/menu.tsx | 26 +++++++++++++++++---- app/routes/machines/machine-actions.ts | 7 ++++++ app/server/headscale/api/resources/nodes.ts | 8 +++++++ tests/integration/api/nodes.test.ts | 15 ++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index a0d2eaad..9cec55cb 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -1,11 +1,12 @@ import { Cog, Ellipsis, SquareTerminal } from "lucide-react"; import { useState } from "react"; +import { useSubmit } from "react-router"; import Button from "~/components/button"; import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu"; import type { User } from "~/types"; import cn from "~/utils/cn"; -import { PopulatedNode } from "~/utils/node-info"; +import { isNoExpiry, type PopulatedNode } from "~/utils/node-info"; import Delete from "../dialogs/delete"; import Expire from "../dialogs/expire"; @@ -35,6 +36,7 @@ export default function MachineMenu({ existingTags, supportsNodeOwnerChange, }: MenuProps) { + const submit = useSubmit(); const [modal, setModal] = useState(null); const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0; @@ -156,15 +158,31 @@ export default function MachineMenu({ setModal("rename")}>Edit machine name + + submit( + { + action_id: "toggle_expiry", + node_id: node.id, + disableExpiry: !isNoExpiry(node.expiry), + }, + { method: "post" }, + ) + } + > + {isNoExpiry(node.expiry) ? "Enable" : "Disable"} key expiry + setModal("routes")}>Edit route settings setModal("tags")}>Edit ACL tags {supportsNodeOwnerChange && ( setModal("move")}>Change owner )} - setModal("expire")}> - Expire - + {!isNoExpiry(node.expiry) && ( + setModal("expire")}> + Expire + + )} setModal("remove")}> Remove diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index f90deb2a..be9a86c0 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -93,6 +93,13 @@ export async function machineAction({ request, context }: Route.ActionArgs) { return { message: "Machine expired" }; } + case "toggle_expiry": { + const disableExpiry = String(formData.get("disableExpiry")) === "true"; + await api.nodes.toggleExpiry(nodeId, disableExpiry); + await context.hsLive.refresh(nodesResource, api); + return { message: "Machine expired" }; + } + case "update_tags": { const tags = formData.get("tags")?.toString().split(",") ?? []; if (tags.length === 0) { diff --git a/app/server/headscale/api/resources/nodes.ts b/app/server/headscale/api/resources/nodes.ts index fb0d487b..1466c790 100644 --- a/app/server/headscale/api/resources/nodes.ts +++ b/app/server/headscale/api/resources/nodes.ts @@ -19,6 +19,7 @@ export interface NodeApi { expire(id: string): Promise; rename(id: string, newName: string): Promise; setTags(id: string, tags: string[]): Promise; + toggleExpiry(nodeId: string, disableExpiry: boolean): Promise; /** * Reassign a node to a different user. Only present when * `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28). @@ -99,6 +100,13 @@ export function makeNodeApi( body: { tags }, }); }, + toggleExpiry: async (nodeId, disableExpiry) => { + await transport.request({ + method: "POST", + path: `v1/node/${nodeId}/expire?disableExpiry=${disableExpiry}`, + apiKey, + }); + }, }; if (!capabilities.nodeOwnerIsImmutable) { diff --git a/tests/integration/api/nodes.test.ts b/tests/integration/api/nodes.test.ts index 91105e2b..eb2d21ce 100644 --- a/tests/integration/api/nodes.test.ts +++ b/tests/integration/api/nodes.test.ts @@ -67,6 +67,21 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { expect(expiredNode.expiry).toBeDefined(); }); + test("key expiry of nodes can be toggled", async () => { + const client = await getRuntimeClient(version); + await client.toggleExpiry(workingNodeId, true); + + const permanentNode = await client.getNode(workingNodeId); + expect(permanentNode).toBeDefined(); + expect(permanentNode.expiry).toBeNull(); + + await client.toggleExpiry(workingNodeId, false); + + const node = await client.getNode(workingNodeId); + expect(node).toBeDefined(); + expect(node.expiry).not.toBeNull(); + }); + test("nodes can be deleted", async () => { const client = await getRuntimeClient(version); await client.nodes.delete(workingNodeId);