mirror of
https://github.com/tale/headplane.git
synced 2026-08-21 18:26:38 +00:00
Add key expiry disable/enable toggle (#554)
This commit is contained in:
@@ -28,6 +28,7 @@ interface Props {
|
|||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
existingTags?: string[];
|
existingTags?: string[];
|
||||||
supportsNodeOwnerChange: boolean;
|
supportsNodeOwnerChange: boolean;
|
||||||
|
supportsDisablingKeyExpiry: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MachineRow({
|
export default function MachineRow({
|
||||||
@@ -38,6 +39,7 @@ export default function MachineRow({
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
existingTags,
|
existingTags,
|
||||||
supportsNodeOwnerChange,
|
supportsNodeOwnerChange,
|
||||||
|
supportsDisablingKeyExpiry,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||||
|
|
||||||
@@ -144,6 +146,7 @@ export default function MachineRow({
|
|||||||
node={node}
|
node={node}
|
||||||
users={users}
|
users={users}
|
||||||
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||||
|
supportsDisablingKeyExpiry={supportsDisablingKeyExpiry}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
|
import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useSubmit } from "react-router";
|
||||||
|
|
||||||
import Button from "~/components/button";
|
import Button from "~/components/button";
|
||||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||||
import type { User } from "~/types";
|
import type { User } from "~/types";
|
||||||
import cn from "~/utils/cn";
|
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 Delete from "../dialogs/delete";
|
||||||
import Expire from "../dialogs/expire";
|
import Expire from "../dialogs/expire";
|
||||||
@@ -22,6 +23,7 @@ interface MenuProps {
|
|||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
existingTags?: string[];
|
existingTags?: string[];
|
||||||
supportsNodeOwnerChange: boolean;
|
supportsNodeOwnerChange: boolean;
|
||||||
|
supportsDisablingKeyExpiry: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Modal = "rename" | "expire" | "remove" | "routes" | "move" | "tags" | null;
|
type Modal = "rename" | "expire" | "remove" | "routes" | "move" | "tags" | null;
|
||||||
@@ -34,7 +36,9 @@ export default function MachineMenu({
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
existingTags,
|
existingTags,
|
||||||
supportsNodeOwnerChange,
|
supportsNodeOwnerChange,
|
||||||
|
supportsDisablingKeyExpiry,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
|
const submit = useSubmit();
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
|
const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
|
||||||
|
|
||||||
@@ -156,15 +160,33 @@ export default function MachineMenu({
|
|||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
<MenuContent>
|
<MenuContent>
|
||||||
<MenuItem onClick={() => setModal("rename")}>Edit machine name</MenuItem>
|
<MenuItem onClick={() => setModal("rename")}>Edit machine name</MenuItem>
|
||||||
|
{supportsDisablingKeyExpiry && (
|
||||||
|
<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("routes")}>Edit route settings</MenuItem>
|
||||||
<MenuItem onClick={() => setModal("tags")}>Edit ACL tags</MenuItem>
|
<MenuItem onClick={() => setModal("tags")}>Edit ACL tags</MenuItem>
|
||||||
{supportsNodeOwnerChange && (
|
{supportsNodeOwnerChange && (
|
||||||
<MenuItem onClick={() => setModal("move")}>Change owner</MenuItem>
|
<MenuItem onClick={() => setModal("move")}>Change owner</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem variant="danger" disabled={node.expired} onClick={() => setModal("expire")}>
|
{!isNoExpiry(node.expiry) && (
|
||||||
Expire
|
<MenuItem variant="danger" disabled={node.expired} onClick={() => setModal("expire")}>
|
||||||
</MenuItem>
|
Expire
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem variant="danger" onClick={() => setModal("remove")}>
|
<MenuItem variant="danger" onClick={() => setModal("remove")}>
|
||||||
Remove
|
Remove
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -113,6 +113,13 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
|||||||
return { message: "Machine expired" };
|
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": {
|
case "update_tags": {
|
||||||
const tags = formData.get("tags")?.toString().split(",") ?? [];
|
const tags = formData.get("tags")?.toString().split(",") ?? [];
|
||||||
if (tags.length === 0) {
|
if (tags.length === 0) {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
const [enhancedNode] = mapNodes([node], stats);
|
const [enhancedNode] = mapNodes([node], stats);
|
||||||
const tags = [...node.tags].toSorted();
|
const tags = [...node.tags].toSorted();
|
||||||
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
|
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
|
||||||
|
const supportsDisablingKeyExpiry = headscale.capabilities.keyExpiryCanBeDisabled;
|
||||||
const agentSync = agents?.lastSync();
|
const agentSync = agents?.lastSync();
|
||||||
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
node: enhancedNode,
|
node: enhancedNode,
|
||||||
stats: stats?.[enhancedNode.nodeKey],
|
stats: stats?.[enhancedNode.nodeKey],
|
||||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||||
|
supportsDisablingKeyExpiry: supportsDisablingKeyExpiry,
|
||||||
tags,
|
tags,
|
||||||
users,
|
users,
|
||||||
};
|
};
|
||||||
@@ -90,7 +92,17 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
export const action = machineAction;
|
export const action = machineAction;
|
||||||
|
|
||||||
export default function Page({
|
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) {
|
}: Route.ComponentProps) {
|
||||||
const [showRouting, setShowRouting] = useState(false);
|
const [showRouting, setShowRouting] = useState(false);
|
||||||
|
|
||||||
@@ -125,6 +137,7 @@ export default function Page({
|
|||||||
node={node}
|
node={node}
|
||||||
users={users}
|
users={users}
|
||||||
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||||
|
supportsDisablingKeyExpiry={supportsDisablingKeyExpiry}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex gap-1">
|
<div className="mb-4 flex gap-1">
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
|
||||||
const populatedNodes = mapNodes(nodes, stats);
|
const populatedNodes = mapNodes(nodes, stats);
|
||||||
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
|
const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
|
||||||
|
const supportsDisablingKeyExpiry = headscale.capabilities.keyExpiryCanBeDisabled;
|
||||||
const agentSync = agents?.lastSync();
|
const agentSync = agents?.lastSync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -86,6 +87,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
publicServer: config.headscale.public_url,
|
publicServer: config.headscale.public_url,
|
||||||
server: config.headscale.url,
|
server: config.headscale.url,
|
||||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||||
|
supportsDisablingKeyExpiry: supportsDisablingKeyExpiry,
|
||||||
users,
|
users,
|
||||||
writable: writablePermission,
|
writable: writablePermission,
|
||||||
};
|
};
|
||||||
@@ -458,6 +460,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
node={node}
|
node={node}
|
||||||
users={loaderData.users}
|
users={loaderData.users}
|
||||||
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
|
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
|
||||||
|
supportsDisablingKeyExpiry={loaderData.supportsDisablingKeyExpiry}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export interface Capabilities {
|
|||||||
* the `hskey-authreq-` prefix. Introduced in 0.29.0.
|
* the `hskey-authreq-` prefix. Introduced in 0.29.0.
|
||||||
*/
|
*/
|
||||||
readonly registerKeyIncludesAuthReqPrefix: boolean;
|
readonly registerKeyIncludesAuthReqPrefix: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disabling of key expiry was introduced in 0.29.0.
|
||||||
|
*/
|
||||||
|
readonly keyExpiryCanBeDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function capabilitiesFor(version: ServerVersion): Capabilities {
|
export function capabilitiesFor(version: ServerVersion): Capabilities {
|
||||||
@@ -52,5 +57,6 @@ export function capabilitiesFor(version: ServerVersion): Capabilities {
|
|||||||
nodeTagsAreFlat: gte(version, "0.28.0"),
|
nodeTagsAreFlat: gte(version, "0.28.0"),
|
||||||
nodeOwnerIsImmutable: gte(version, "0.28.0"),
|
nodeOwnerIsImmutable: gte(version, "0.28.0"),
|
||||||
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"),
|
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"),
|
||||||
|
keyExpiryCanBeDisabled: gte(version, "0.29.0"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface NodeApi {
|
|||||||
expire(id: string): Promise<void>;
|
expire(id: string): Promise<void>;
|
||||||
rename(id: string, newName: string): Promise<void>;
|
rename(id: string, newName: string): Promise<void>;
|
||||||
setTags(id: string, tags: 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
|
* Reassign a node to a different user. Only present when
|
||||||
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
|
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
|
||||||
@@ -104,6 +105,13 @@ export function makeNodeApi(
|
|||||||
body: { tags },
|
body: { tags },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
toggleExpiry: async (nodeId, disableExpiry) => {
|
||||||
|
await transport.request({
|
||||||
|
method: "POST",
|
||||||
|
path: `v1/node/${nodeId}/expire?disableExpiry=${disableExpiry}`,
|
||||||
|
apiKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!capabilities.nodeOwnerIsImmutable) {
|
if (!capabilities.nodeOwnerIsImmutable) {
|
||||||
|
|||||||
@@ -111,6 +111,27 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
|||||||
expect(expiredNode.expiry).toBeDefined();
|
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 () => {
|
test("nodes can be deleted", async () => {
|
||||||
const client = await getRuntimeClient(version);
|
const client = await getRuntimeClient(version);
|
||||||
await client.nodes.delete(workingNodeId);
|
await client.nodes.delete(workingNodeId);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ describe("capabilitiesFor", () => {
|
|||||||
nodeTagsAreFlat: true,
|
nodeTagsAreFlat: true,
|
||||||
nodeOwnerIsImmutable: true,
|
nodeOwnerIsImmutable: true,
|
||||||
registerKeyIncludesAuthReqPrefix: false,
|
registerKeyIncludesAuthReqPrefix: false,
|
||||||
|
keyExpiryCanBeDisabled: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user