Compare commits

...

1 Commits

Author SHA1 Message Date
Aarnav Tale 623e7c03f1 fix: encode headscale rename path segments 2026-05-14 12:40:43 -04:00
4 changed files with 143 additions and 75 deletions
+5 -2
View File
@@ -1,7 +1,6 @@
import type { Machine } from "~/types"; import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from ".."; import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory"; import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> { interface RawMachine extends Omit<Machine, "tags"> {
@@ -146,7 +145,11 @@ export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
}, },
renameNode: async (nodeId, newName) => { renameNode: async (nodeId, newName) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey); await client.apiFetch<void>(
"POST",
`v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
}, },
setNodeTags: async (nodeId, tags) => { setNodeTags: async (nodeId, tags) => {
+71 -72
View File
@@ -1,88 +1,87 @@
import type { User } from '~/types'; import type { User } from "~/types";
import { defineApiEndpoints } from '../factory';
import { defineApiEndpoints } from "../factory";
export interface UserEndpoints { export interface UserEndpoints {
/** /**
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
* *
* @param id Optional ID of the user to retrieve. * @param id Optional ID of the user to retrieve.
* @param name Optional name of the user to retrieve. * @param name Optional name of the user to retrieve.
* @param email Optional email of the user to retrieve. * @param email Optional email of the user to retrieve.
* @returns An array of `User` objects representing the users. * @returns An array of `User` objects representing the users.
*/ */
getUsers(id?: string, name?: string, email?: string): Promise<User[]>; getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
/** /**
* Creates a new user in the Headscale instance. * Creates a new user in the Headscale instance.
* *
* @param username The username of the new user. * @param username The username of the new user.
* @param email Optional email of the new user. * @param email Optional email of the new user.
* @param displayName Optional display name of the new user. * @param displayName Optional display name of the new user.
* @param pictureUrl Optional picture URL of the new user. * @param pictureUrl Optional picture URL of the new user.
* @returns A `User` object representing the newly created user. * @returns A `User` object representing the newly created user.
*/ */
createUser( createUser(
username: string, username: string,
email?: string, email?: string,
displayName?: string, displayName?: string,
pictureUrl?: string, pictureUrl?: string,
): Promise<User>; ): Promise<User>;
/** /**
* Deletes a specific user by its ID. * Deletes a specific user by its ID.
* *
* @param id The ID of the user to delete. * @param id The ID of the user to delete.
*/ */
deleteUser(id: string): Promise<void>; deleteUser(id: string): Promise<void>;
/** /**
* Renames a specific user by its ID. * Renames a specific user by its ID.
* *
* @param id The ID of the user to rename. * @param id The ID of the user to rename.
* @param newName The new name for the user. * @param newName The new name for the user.
*/ */
renameUser(id: string, newName: string): Promise<void>; renameUser(id: string, newName: string): Promise<void>;
} }
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({ export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
getUsers: async (id, name, email) => { getUsers: async (id, name, email) => {
const moreThanOneFilter = const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
[id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) { if (moreThanOneFilter) {
throw new Error('Only one of id, name, or email filters can be provided'); throw new Error("Only one of id, name, or email filters can be provided");
} }
const { users } = await client.apiFetch<{ users: User[] }>( const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, {
'GET', id,
'v1/user', name,
apiKey, email,
{ id, name, email }, });
);
return users; return users;
}, },
createUser: async (username, email, displayName, pictureUrl) => { createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>( const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, {
'POST', name: username,
'v1/user', email,
apiKey, displayName,
{ name: username, email, displayName, pictureUrl }, pictureUrl,
); });
return user; return user;
}, },
deleteUser: async (id) => { deleteUser: async (id) => {
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey); await client.apiFetch<void>("DELETE", `v1/user/${id}`, apiKey);
}, },
renameUser: async (oldId, newName) => { renameUser: async (oldId, newName) => {
await client.apiFetch<void>( await client.apiFetch<void>(
'POST', "POST",
`v1/user/${oldId}/rename/${newName}`, `v1/user/${oldId}/rename/${encodeURIComponent(newName)}`,
apiKey, apiKey,
); );
}, },
})); }));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "headplane", "name": "headplane",
"version": "0.6.2", "version": "0.6.3",
"private": true, "private": true,
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from "vitest";
import type { HeadscaleApiInterface } from "~/server/headscale/api";
import nodeEndpoints from "~/server/headscale/api/endpoints/nodes";
import userEndpoints from "~/server/headscale/api/endpoints/users";
type ApiFetchArgs = Parameters<HeadscaleApiInterface["clientHelpers"]["apiFetch"]>;
function createApiClientRecorder() {
const calls: Array<{
method: ApiFetchArgs[0];
apiPath: ApiFetchArgs[1];
apiKey: ApiFetchArgs[2];
bodyOrQuery: ApiFetchArgs[3];
}> = [];
const client = {
isAtleast: () => false,
rawFetch: async () => {
throw new Error("rawFetch should not be called");
},
apiFetch: async <T>(
method: ApiFetchArgs[0],
apiPath: ApiFetchArgs[1],
apiKey: ApiFetchArgs[2],
bodyOrQuery?: ApiFetchArgs[3],
) => {
calls.push({ method, apiPath, apiKey, bodyOrQuery });
return undefined as T;
},
} as HeadscaleApiInterface["clientHelpers"];
return { calls, client };
}
describe("Headscale API path encoding", () => {
test("encodes node rename names as a single URL segment", async () => {
const { calls, client } = createApiClientRecorder();
await nodeEndpoints(client, "api-key").renameNode("2", "../../1/expire");
expect(calls).toEqual([
{
method: "POST",
apiPath: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
apiKey: "api-key",
bodyOrQuery: undefined,
},
]);
});
test("encodes user rename names as a single URL segment", async () => {
const { calls, client } = createApiClientRecorder();
await userEndpoints(client, "api-key").renameUser("3", "../../../user/4/rename/pwned-user@");
expect(calls).toEqual([
{
method: "POST",
apiPath: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40",
apiKey: "api-key",
bodyOrQuery: undefined,
},
]);
});
});