feat: rework the machine actions

this also fixes the registration regression introduced in 0.5.8
This commit is contained in:
Aarnav Tale 2025-04-16 01:48:38 -04:00
parent c1716a15ae
commit 6ace244401
No known key found for this signature in database
9 changed files with 215 additions and 222 deletions

View File

@ -10,6 +10,7 @@
- Refer to the `integrations.agent` section of the config file for more information and how to enable it. - Refer to the `integrations.agent` section of the config file for more information and how to enable it.
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)). - Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)). - The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
### 0.5.10 (April 4, 2025) ### 0.5.10 (April 4, 2025)
- Fix an issue where other preferences to skip onboarding affected every user. - Fix an issue where other preferences to skip onboarding affected every user.

View File

@ -22,8 +22,8 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
This machine will be permanently removed from your network. To re-add This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device. it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="delete" /> <input type="hidden" name="action_id" value="delete" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="node_id" value={machine.id} />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );

View File

@ -16,8 +16,8 @@ export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
This will disconnect the machine from your Tailnet. In order to This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device. reconnect, you will need to re-authenticate from the device.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="expire" /> <input type="hidden" name="action_id" value="expire" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="node_id" value={machine.id} />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );

View File

@ -17,11 +17,11 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
<Dialog.Text> <Dialog.Text>
The owner of the machine is the user associated with it. The owner of the machine is the user associated with it.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="move" /> <input type="hidden" name="action_id" value="reassign" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="node_id" value={machine.id} />
<Select <Select
label="Owner" label="Owner"
name="to" name="user"
placeholder="Select a user" placeholder="Select a user"
defaultSelectedKey={machine.user.id} defaultSelectedKey={machine.user.id}
> >

View File

@ -30,14 +30,13 @@ export default function NewMachine(data: NewMachineProps) {
<Code isCopyable>tailscale up --login-server={data.server}</Code> on <Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device. your device.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="register" /> <input type="hidden" name="action_id" value="register" />
<input type="hidden" name="id" value="_" />
<Input <Input
isRequired isRequired
label="Machine Key" label="Machine Key"
placeholder="AbCd..." placeholder="AbCd..."
validationBehavior="native" validationBehavior="native"
name="mkey" name="register_key"
onChange={setMkey} onChange={setMkey}
/> />
<Select <Select

View File

@ -27,8 +27,8 @@ export default function Rename({
This name is shown in the admin panel, in Tailscale clients, and used This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names. when generating MagicDNS names.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="rename" /> <input type="hidden" name="action_id" value="rename" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="node_id" value={machine.id} />
<Input <Input
label="Machine name" label="Machine name"
placeholder="Machine name" placeholder="Machine name"

View File

@ -78,9 +78,9 @@ export default function Routes({
label="Enabled" label="Enabled"
onChange={(checked) => { onChange={(checked) => {
const form = new FormData(); const form = new FormData();
form.set('id', machine.id); form.set('action_id', 'update_routes');
form.set('_method', 'routes'); form.set('node_id', machine.id);
form.set('route', route.id); form.set('routes', [route.id].join(','));
form.set('enabled', String(checked)); form.set('enabled', String(checked));
fetcher.submit(form, { fetcher.submit(form, {
@ -115,8 +115,8 @@ export default function Routes({
label="Enabled" label="Enabled"
onChange={(checked) => { onChange={(checked) => {
const form = new FormData(); const form = new FormData();
form.set('id', machine.id); form.set('action_id', 'update_routes');
form.set('_method', 'exit-node'); form.set('node_id', machine.id);
form.set('routes', exit.map((route) => route.id).join(',')); form.set('routes', exit.map((route) => route.id).join(','));
form.set('enabled', String(checked)); form.set('enabled', String(checked));

View File

@ -33,8 +33,8 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
</Link>{' '} </Link>{' '}
for more information. for more information.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="_method" value="tags" /> <input type="hidden" name="action_id" value="update_tags" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="node_id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} /> <input type="hidden" name="tags" value={tags.join(',')} />
<TableList className="mt-4"> <TableList className="mt-4">
{tags.length === 0 ? ( {tags.length === 0 ? (

View File

@ -1,11 +1,8 @@
import type { ActionFunctionArgs } from 'react-router'; import { type ActionFunctionArgs, data, redirect } from 'react-router';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types'; import { Machine } from '~/types';
import log from '~/utils/log';
import { data400, data403, data404, send } from '~/utils/res';
// TODO: Clean this up like dns-actions and user-actions
export async function machineAction({ export async function machineAction({
request, request,
context, context,
@ -16,14 +13,33 @@ export async function machineAction({
Capabilities.write_machines, Capabilities.write_machines,
); );
const apiKey = session.get('api_key')!;
const formData = await request.formData(); const formData = await request.formData();
const apiKey = session.get('api_key')!;
// TODO: Rename this to 'action_id' and 'node_id' const action = formData.get('action_id')?.toString();
const action = formData.get('_method')?.toString(); if (!action) {
const nodeId = formData.get('id')?.toString(); throw data('Missing `action_id` in the form data.', {
if (!action || !nodeId) { status: 400,
return data400('Missing required parameters: _method and id'); });
}
// 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,
});
}
return registerMachine(formData, apiKey, context);
}
// 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 { nodes } = await context.client.get<{ nodes: Machine[] }>( const { nodes } = await context.client.get<{ nodes: Machine[] }>(
@ -33,215 +49,192 @@ export async function machineAction({
const node = nodes.find((node) => node.id === nodeId); const node = nodes.find((node) => node.id === nodeId);
if (!node) { if (!node) {
return data404(`Node with ID ${nodeId} not found`); throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
});
} }
const subject = session.get('user')!.subject; if (
if (node.user.providerId?.split('/').pop() !== subject) { node.user.providerId?.split('/').pop() !== session.get('user')!.subject &&
if (!check) { !check
return data403('You do not have permission to act on this machine'); ) {
} throw data('You do not have permission to act on this machine', {
status: 403,
});
} }
// TODO: Split up into methods
switch (action) { switch (action) {
case 'rename': {
return renameMachine(formData, apiKey, nodeId, context);
}
case 'delete': { case 'delete': {
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!); return deleteMachine(apiKey, nodeId, context);
return { message: 'Machine removed' };
} }
case 'expire': { case 'expire': {
await context.client.post( return expireMachine(apiKey, nodeId, context);
`v1/node/${nodeId}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
} }
case 'rename': { case 'update_tags': {
if (!formData.has('name')) { return updateTags(formData, apiKey, nodeId, context);
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(formData.get('name'));
await context.client.post(
`v1/node/${nodeId}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
} }
case 'routes': { case 'update_routes': {
if (!formData.has('route') || !formData.has('enabled')) { return updateRoutes(formData, apiKey, nodeId, context);
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(formData.get('route'));
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
return { message: 'Route updated' };
} }
case 'exit-node': { case 'reassign': {
if (!formData.has('routes') || !formData.has('enabled')) { return reassignMachine(formData, apiKey, nodeId, context);
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const routes = formData.get('routes')?.toString().split(',') ?? [];
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
routes.map(async (route) => {
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
}),
);
return { message: 'Exit node updated' };
} }
case 'move': { default:
if (!formData.has('to')) { throw data('Invalid action', {
return send( status: 400,
{ message: 'No destination provided' }, });
{
status: 400,
},
);
}
const to = String(formData.get('to'));
try {
await context.client.post(
`v1/node/${nodeId}/user`,
session.get('api_key')!,
{
user: to,
},
);
return { message: `Moved node ${nodeId} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${nodeId} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags =
formData
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await context.client.post(
`v1/node/${nodeId}/tags`,
session.get('api_key')!,
{
tags,
},
);
return { message: 'Tags updated' };
} catch (error) {
log.debug('api', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = formData.get('mkey')?.toString();
const user = formData.get('user')?.toString();
if (!key) {
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
if (!user) {
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
try {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await context.client.post(url, session.get('api_key')!, {
user,
key,
});
return {
success: true,
message: 'Machine registered',
};
} catch {
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
} }
} }
async function registerMachine(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
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 qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', registrationKey);
const url = `v1/node/register?${qp.toString()}`;
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
user,
key: registrationKey,
});
return redirect(`/machines/${node.id}`);
}
async function renameMachine(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
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 context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
return { message: 'Machine renamed' };
}
async function deleteMachine(
apiKey: string,
nodeId: string,
context: LoadContext,
) {
await context.client.delete(`v1/node/${nodeId}`, apiKey);
return redirect('/machines');
}
async function expireMachine(
apiKey: string,
nodeId: string,
context: LoadContext,
) {
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
return { message: 'Machine expired' };
}
async function updateTags(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const tags = formData.get('tags')?.toString().split(',') ?? [];
if (tags.length === 0) {
throw data('Missing `tags` in the form data.', {
status: 400,
});
}
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
});
return { message: 'Tags updated' };
}
async function updateRoutes(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const routes = formData.get('routes')?.toString();
if (!routes) {
throw data('Missing `routes` in the form data.', {
status: 400,
});
}
const allRoutes = routes.split(',').map((route) => route.trim());
if (allRoutes.length === 0) {
throw data('No routes provided to update', {
status: 400,
});
}
const enabled = formData.get('enabled')?.toString();
if (enabled === undefined) {
throw data('Missing `enabled` in the form data.', {
status: 400,
});
}
const postfix = enabled === 'true' ? 'enable' : 'disable';
await Promise.all(
allRoutes.map(async (route) => {
await context.client.post(`v1/routes/${route}/${postfix}`, apiKey);
}),
);
return { message: 'Routes updated' };
}
async function reassignMachine(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const user = formData.get('user')?.toString();
if (!user) {
throw data('Missing `user` in the form data.', {
status: 400,
});
}
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
user,
});
return { message: 'Machine reassigned' };
}