Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions app/routes/machines/components/menu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -35,6 +36,7 @@ export default function MachineMenu({
existingTags,
supportsNodeOwnerChange,
}: MenuProps) {
const submit = useSubmit();
const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;

Expand Down Expand Up @@ -156,15 +158,31 @@ export default function MachineMenu({
</MenuTrigger>
<MenuContent>
<MenuItem onClick={() => setModal("rename")}>Edit machine name</MenuItem>
<MenuItem
onClick={() =>
submit(
{
action_id: "toggle_expiry",
node_id: node.id,
disableExpiry: !isNoExpiry(node.expiry),
},
{ method: "post" },
)
}
>
{isNoExpiry(node.expiry) ? "Enable" : "Disable"} key expiry
</MenuItem>
<MenuItem onClick={() => setModal("routes")}>Edit route settings</MenuItem>
<MenuItem onClick={() => setModal("tags")}>Edit ACL tags</MenuItem>
{supportsNodeOwnerChange && (
<MenuItem onClick={() => setModal("move")}>Change owner</MenuItem>
)}
<MenuSeparator />
<MenuItem variant="danger" disabled={node.expired} onClick={() => setModal("expire")}>
Expire
</MenuItem>
{!isNoExpiry(node.expiry) && (
<MenuItem variant="danger" disabled={node.expired} onClick={() => setModal("expire")}>
Expire
</MenuItem>
)}
<MenuItem variant="danger" onClick={() => setModal("remove")}>
Remove
</MenuItem>
Expand Down
7 changes: 7 additions & 0 deletions app/routes/machines/machine-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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.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) {
Expand Down
16 changes: 16 additions & 0 deletions app/server/headscale/api/endpoints/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export interface NodeEndpoints {
*/
expireNode(id: string): Promise<void>;

/**
* Toggle expiry of a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
* @param disableExpiry `true` if machine shall have key expiry disabled, `false` otherwise.
*/
toggleExpiry(id: string, disableExpiry: boolean): Promise<void>;

/**
* Renames a specific node (machine) by its ID.
*
Expand Down Expand Up @@ -144,6 +152,14 @@ export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
},

toggleExpiry: async (nodeId, disableExpiry) => {
await client.apiFetch<void>(
"POST",
`v1/node/${nodeId}/expire?disableExpiry=${disableExpiry}`,
apiKey,
);
},

renameNode: async (nodeId, newName) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
},
Expand Down
15 changes: 15 additions & 0 deletions tests/integration/api/nodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@
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();

Check failure on line 74 in tests/integration/api/nodes.test.ts

View workflow job for this annotation

GitHub Actions / Build and Test

[integration:api] tests/integration/api/nodes.test.ts > Headscale 0.28.0: Users > key expiry of nodes can be toggled

AssertionError: expected '2026-05-07T20:46:15.756939461Z' to be null - Expected: null + Received: "2026-05-07T20:46:15.756939461Z" ❯ tests/integration/api/nodes.test.ts:74:34

Check failure on line 74 in tests/integration/api/nodes.test.ts

View workflow job for this annotation

GitHub Actions / Build and Test

[integration:api] tests/integration/api/nodes.test.ts > Headscale 0.27.1: Users > key expiry of nodes can be toggled

AssertionError: expected '2026-05-07T20:46:11.274787539Z' to be null - Expected: null + Received: "2026-05-07T20:46:11.274787539Z" ❯ tests/integration/api/nodes.test.ts:74:34

Check failure on line 74 in tests/integration/api/nodes.test.ts

View workflow job for this annotation

GitHub Actions / Build and Test

[integration:api] tests/integration/api/nodes.test.ts > Headscale 0.27.0: Users > key expiry of nodes can be toggled

AssertionError: expected '2026-05-07T20:46:05.930804911Z' to be null - Expected: null + Received: "2026-05-07T20:46:05.930804911Z" ❯ tests/integration/api/nodes.test.ts:74:34

Check failure on line 74 in tests/integration/api/nodes.test.ts

View workflow job for this annotation

GitHub Actions / Build and Test

[integration:api] tests/integration/api/nodes.test.ts > Headscale 0.26.1: Users > key expiry of nodes can be toggled

AssertionError: expected '2026-05-07T20:45:59.683749752Z' to be null - Expected: null + Received: "2026-05-07T20:45:59.683749752Z" ❯ tests/integration/api/nodes.test.ts:74:34

Check failure on line 74 in tests/integration/api/nodes.test.ts

View workflow job for this annotation

GitHub Actions / Build and Test

[integration:api] tests/integration/api/nodes.test.ts > Headscale 0.26.0: Users > key expiry of nodes can be toggled

AssertionError: expected '2026-05-07T20:45:55.427316486Z' to be null - Expected: null + Received: "2026-05-07T20:45:55.427316486Z" ❯ tests/integration/api/nodes.test.ts:74:34

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.deleteNode(workingNodeId);
Expand Down
Loading