mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: official 0.28 support possibly
This commit is contained in:
@@ -2,6 +2,5 @@
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"],
|
||||
"0.28.0-beta.1": ["0.28.0-beta.1"],
|
||||
"0.28.0-beta.2": ["0.28.0-beta.2"]
|
||||
"0.28.0": ["0.28.0"]
|
||||
}
|
||||
|
||||
@@ -105,34 +105,7 @@
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.28.0-beta.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"DELETE /api/v1/preauthkey": "fa2975a185782e5d",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.28.0-beta.2": {
|
||||
"0.28.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plus, TagsIcon, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
@@ -18,6 +19,8 @@ interface TagsProps {
|
||||
}
|
||||
|
||||
export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
|
||||
const fetcher = useFetcher();
|
||||
const submittingRef = useRef(false);
|
||||
const [tags, setTags] = useState([...machine.tags]);
|
||||
const [tag, setTag] = useState("tag:");
|
||||
const tagIsInvalid = useMemo(() => {
|
||||
@@ -28,9 +31,45 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
|
||||
}, [tags]);
|
||||
|
||||
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.success) {
|
||||
submittingRef.current = false;
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
if (fetcher.state === "idle" && fetcher.data && !fetcher.data.success) {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
}, [fetcher.data, fetcher.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTags([...machine.tags]);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && submittingRef.current) return;
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<Dialog.Panel
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submittingRef.current = true;
|
||||
const form = new FormData();
|
||||
form.set("action_id", "update_tags");
|
||||
form.set("node_id", machine.id);
|
||||
form.set("tags", tags.filter((t) => t !== "").join(","));
|
||||
fetcher.submit(form, { method: "POST" });
|
||||
}}
|
||||
isDisabled={fetcher.state !== "idle"}
|
||||
>
|
||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
ACL tags can be used to reference machines in your ACL policies. See the{" "}
|
||||
@@ -39,9 +78,11 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
</Link>{" "}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="update_tags" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="tags" type="hidden" value={tags.join(",")} />
|
||||
{error ? (
|
||||
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
@@ -90,6 +131,10 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm opacity-50">
|
||||
Not seeing the tags you expect? Tags need to be defined in your access control policy
|
||||
before they can be assigned to machines.
|
||||
</p>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,178 +1,191 @@
|
||||
import { data, redirect } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/machine';
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.write_machines);
|
||||
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
throw data("Missing `action_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Fast track register since it doesn't require an existing machine
|
||||
if (action === 'register') {
|
||||
if (!check) {
|
||||
throw data('You do not have permission to manage machines', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
// Fast track register since it doesn't require an existing machine
|
||||
if (action === "register") {
|
||||
if (!check) {
|
||||
throw data("You do not have permission to manage machines", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const registrationKey = formData.get("register_key")?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data("Missing `register_key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const user = formData.get("user")?.toString();
|
||||
if (!user) {
|
||||
throw data("Missing `user` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
// Check if the user has permission to manage this machine
|
||||
const nodeId = formData.get('node_id')?.toString();
|
||||
if (!nodeId) {
|
||||
throw data('Missing `node_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// Check if the user has permission to manage this machine
|
||||
const nodeId = formData.get("node_id")?.toString();
|
||||
if (!nodeId) {
|
||||
throw data("Missing `node_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
const node = await api.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
node.user.providerId?.split('/').pop() !== session.user.subject &&
|
||||
!check
|
||||
) {
|
||||
throw data('You do not have permission to act on this machine', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (node.user.providerId?.split("/").pop() !== session.user.subject && !check) {
|
||||
throw data("You do not have permission to act on this machine", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'rename': {
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
switch (action) {
|
||||
case "rename": {
|
||||
const newName = formData.get("name")?.toString();
|
||||
if (!newName) {
|
||||
throw data("Missing `name` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await api.renameNode(nodeId, name);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
const name = String(formData.get("name"));
|
||||
await api.renameNode(nodeId, name);
|
||||
return { message: "Machine renamed" };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
await api.deleteNode(nodeId);
|
||||
return redirect('/machines');
|
||||
}
|
||||
case "delete": {
|
||||
await api.deleteNode(nodeId);
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await api.expireNode(nodeId);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
case "expire": {
|
||||
await api.expireNode(nodeId);
|
||||
return { message: "Machine expired" };
|
||||
}
|
||||
|
||||
case 'update_tags': {
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
case "update_tags": {
|
||||
const tags = formData.get("tags")?.toString().split(",") ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data("Missing `tags` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
);
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
try {
|
||||
await api.setNodeTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
||||
);
|
||||
|
||||
case 'update_routes': {
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return { success: true as const, message: "Tags updated" };
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error) && error.data.statusCode === 400) {
|
||||
return data(
|
||||
{
|
||||
success: false as const,
|
||||
error:
|
||||
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
case "update_routes": {
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get("routes")?.toString();
|
||||
if (!routes) {
|
||||
throw data("Missing `routes` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled === 'true') {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
const allRoutes = routes.split(",").map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data("No routes provided to update", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
const enabled = formData.get("enabled")?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data("Missing `enabled` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enabled === "true") {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
case 'reassign': {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
return { message: "Routes updated" };
|
||||
}
|
||||
|
||||
default:
|
||||
throw data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
case "reassign": {
|
||||
const user = formData.get("user_id")?.toString();
|
||||
if (!user) {
|
||||
throw data("Missing `user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
return { message: "Machine reassigned" };
|
||||
}
|
||||
|
||||
default:
|
||||
throw data("Invalid action", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface RawMachine extends Omit<Machine, "tags"> {
|
||||
* @returns A Machine object with normalized tags.
|
||||
*/
|
||||
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
|
||||
if (client.isAtleast("0.28.0-beta.1")) {
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
return { ...node, tags: node.tags ?? [] } as Machine;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ export interface NodeEndpoints {
|
||||
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
|
||||
|
||||
return nodes.map((node) => normalizeTags(client, node));
|
||||
},
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ const SPEC_MAP = {
|
||||
"0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.28.0-beta.1": "/v0.28.0-beta.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.28.0-beta.2": "/v0.28.0-beta.2/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.28.0": "/v0.28.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
} as const;
|
||||
|
||||
async function hashOpenApiOperations(specUrl: string) {
|
||||
|
||||
Reference in New Issue
Block a user