diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index d6bc4e52..0d764513 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -28,6 +28,7 @@ interface Props { isDisabled?: boolean; existingTags?: string[]; supportsNodeOwnerChange: boolean; + supportsDisablingKeyExpiry: boolean; } export default function MachineRow({ @@ -38,6 +39,7 @@ export default function MachineRow({ isDisabled, existingTags, supportsNodeOwnerChange, + supportsDisablingKeyExpiry, }: Props) { const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]); @@ -144,6 +146,7 @@ export default function MachineRow({ node={node} users={users} supportsNodeOwnerChange={supportsNodeOwnerChange} + supportsDisablingKeyExpiry={supportsDisablingKeyExpiry} /> diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index a0d2eaad..ba366f6c 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"; @@ -22,6 +23,7 @@ interface MenuProps { isDisabled?: boolean; existingTags?: string[]; supportsNodeOwnerChange: boolean; + supportsDisablingKeyExpiry: boolean; } type Modal = "rename" | "expire" | "remove" | "routes" | "move" | "tags" | null; @@ -34,7 +36,9 @@ export default function MachineMenu({ isDisabled, existingTags, supportsNodeOwnerChange, + supportsDisablingKeyExpiry, }: MenuProps) { + const submit = useSubmit(); const [modal, setModal] = useState(null); const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0; @@ -156,15 +160,33 @@ export default function MachineMenu({ setModal("rename")}>Edit machine name + {supportsDisablingKeyExpiry && ( + + 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 fbb0a855..81d26643 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -113,6 +113,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 headscaleLiveStore.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/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index cca5412b..ffece95b 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -66,6 +66,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { const [enhancedNode] = mapNodes([node], stats); const tags = [...node.tags].toSorted(); const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable; + const supportsDisablingKeyExpiry = headscale.capabilities.keyExpiryCanBeDisabled; const agentSync = agents?.lastSync(); const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; @@ -82,6 +83,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { node: enhancedNode, stats: stats?.[enhancedNode.nodeKey], supportsNodeOwnerChange: supportsNodeOwnerChange, + supportsDisablingKeyExpiry: supportsDisablingKeyExpiry, tags, users, }; @@ -90,7 +92,17 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { export const action = machineAction; export default function Page({ - loaderData: { node, tags, users, magic, agent, stats, existingTags, supportsNodeOwnerChange }, + loaderData: { + node, + tags, + users, + magic, + agent, + stats, + existingTags, + supportsNodeOwnerChange, + supportsDisablingKeyExpiry, + }, }: Route.ComponentProps) { const [showRouting, setShowRouting] = useState(false); @@ -125,6 +137,7 @@ export default function Page({ node={node} users={users} supportsNodeOwnerChange={supportsNodeOwnerChange} + supportsDisablingKeyExpiry={supportsDisablingKeyExpiry} />
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 18d529b3..b082aa51 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -67,6 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; const populatedNodes = mapNodes(nodes, stats); const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable; + const supportsDisablingKeyExpiry = headscale.capabilities.keyExpiryCanBeDisabled; const agentSync = agents?.lastSync(); return { @@ -86,6 +87,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { publicServer: config.headscale.public_url, server: config.headscale.url, supportsNodeOwnerChange: supportsNodeOwnerChange, + supportsDisablingKeyExpiry: supportsDisablingKeyExpiry, users, writable: writablePermission, }; @@ -458,6 +460,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { node={node} users={loaderData.users} supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange} + supportsDisablingKeyExpiry={loaderData.supportsDisablingKeyExpiry} /> )) )} diff --git a/app/server/headscale/api/capabilities.ts b/app/server/headscale/api/capabilities.ts index b3e98465..61c1e4a1 100644 --- a/app/server/headscale/api/capabilities.ts +++ b/app/server/headscale/api/capabilities.ts @@ -44,6 +44,11 @@ export interface Capabilities { * the `hskey-authreq-` prefix. Introduced in 0.29.0. */ readonly registerKeyIncludesAuthReqPrefix: boolean; + + /** + * Disabling of key expiry was introduced in 0.29.0. + */ + readonly keyExpiryCanBeDisabled: boolean; } export function capabilitiesFor(version: ServerVersion): Capabilities { @@ -52,5 +57,6 @@ export function capabilitiesFor(version: ServerVersion): Capabilities { nodeTagsAreFlat: gte(version, "0.28.0"), nodeOwnerIsImmutable: gte(version, "0.28.0"), registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"), + keyExpiryCanBeDisabled: gte(version, "0.29.0"), }; } diff --git a/app/server/headscale/api/resources/nodes.ts b/app/server/headscale/api/resources/nodes.ts index 6fef30a8..d18b492c 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). @@ -104,6 +105,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 27b4a3e8..57bfb353 100644 --- a/tests/integration/api/nodes.test.ts +++ b/tests/integration/api/nodes.test.ts @@ -111,6 +111,27 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { expect(expiredNode.expiry).toBeDefined(); }); + test("key expiry of nodes can be toggled", async (context) => { + const bootstrap = await getBootstrapClient(version); + // Key expiry was introduced in 0.29.0 + if (!bootstrap.capabilities.keyExpiryCanBeDisabled) { + context.skip(); + } + + const client = await getRuntimeClient(version); + await client.nodes.toggleExpiry(workingNodeId, true); + + const permanentNode = await client.nodes.get(workingNodeId); + expect(permanentNode).toBeDefined(); + expect(permanentNode.expiry).toBeNull(); + + await client.nodes.toggleExpiry(workingNodeId, false); + + const node = await client.nodes.get(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); diff --git a/tests/unit/headscale/server-version.test.ts b/tests/unit/headscale/server-version.test.ts index 9aa5c760..a4c7289d 100644 --- a/tests/unit/headscale/server-version.test.ts +++ b/tests/unit/headscale/server-version.test.ts @@ -98,6 +98,7 @@ describe("capabilitiesFor", () => { nodeTagsAreFlat: true, nodeOwnerIsImmutable: true, registerKeyIncludesAuthReqPrefix: false, + keyExpiryCanBeDisabled: false, }); });