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 @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions app/server/headscale/api/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"),
};
}
8 changes: 8 additions & 0 deletions app/server/headscale/api/resources/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface NodeApi {
expire(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
setTags(id: string, tags: string[]): Promise<void>;
toggleExpiry(nodeId: string, disableExpiry: boolean): Promise<void>;
/**
* Reassign a node to a different user. Only present when
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/api/nodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions tests/unit/headscale/server-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ describe("capabilitiesFor", () => {
nodeTagsAreFlat: true,
nodeOwnerIsImmutable: true,
registerKeyIncludesAuthReqPrefix: false,
keyExpiryCanBeDisabled: false,
});
});

Expand Down
Loading