From 623e7c03f12f6e46bd956feb48b154d380a21a7e Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Thu, 14 May 2026 12:31:52 -0400 Subject: [PATCH] fix: encode headscale rename path segments --- app/server/headscale/api/endpoints/nodes.ts | 7 +- app/server/headscale/api/endpoints/users.ts | 143 ++++++++++---------- package.json | 2 +- tests/unit/headscale/api-paths.test.ts | 66 +++++++++ 4 files changed, 143 insertions(+), 75 deletions(-) create mode 100644 tests/unit/headscale/api-paths.test.ts diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts index e9289e1..e26d6ac 100644 --- a/app/server/headscale/api/endpoints/nodes.ts +++ b/app/server/headscale/api/endpoints/nodes.ts @@ -1,7 +1,6 @@ import type { Machine } from "~/types"; import type { HeadscaleApiInterface } from ".."; - import { defineApiEndpoints } from "../factory"; interface RawMachine extends Omit { @@ -146,7 +145,11 @@ export default defineApiEndpoints((client, apiKey) => ({ }, renameNode: async (nodeId, newName) => { - await client.apiFetch("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey); + await client.apiFetch( + "POST", + `v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`, + apiKey, + ); }, setNodeTags: async (nodeId, tags) => { diff --git a/app/server/headscale/api/endpoints/users.ts b/app/server/headscale/api/endpoints/users.ts index 8da52ca..c82f7e6 100644 --- a/app/server/headscale/api/endpoints/users.ts +++ b/app/server/headscale/api/endpoints/users.ts @@ -1,88 +1,87 @@ -import type { User } from '~/types'; -import { defineApiEndpoints } from '../factory'; +import type { User } from "~/types"; + +import { defineApiEndpoints } from "../factory"; export interface UserEndpoints { - /** - * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. - * - * @param id Optional ID of the user to retrieve. - * @param name Optional name of the user to retrieve. - * @param email Optional email of the user to retrieve. - * @returns An array of `User` objects representing the users. - */ - getUsers(id?: string, name?: string, email?: string): Promise; + /** + * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. + * + * @param id Optional ID of the user to retrieve. + * @param name Optional name of the user to retrieve. + * @param email Optional email of the user to retrieve. + * @returns An array of `User` objects representing the users. + */ + getUsers(id?: string, name?: string, email?: string): Promise; - /** - * Creates a new user in the Headscale instance. - * - * @param username The username of the new user. - * @param email Optional email of the new user. - * @param displayName Optional display name of the new user. - * @param pictureUrl Optional picture URL of the new user. - * @returns A `User` object representing the newly created user. - */ - createUser( - username: string, - email?: string, - displayName?: string, - pictureUrl?: string, - ): Promise; + /** + * Creates a new user in the Headscale instance. + * + * @param username The username of the new user. + * @param email Optional email of the new user. + * @param displayName Optional display name of the new user. + * @param pictureUrl Optional picture URL of the new user. + * @returns A `User` object representing the newly created user. + */ + createUser( + username: string, + email?: string, + displayName?: string, + pictureUrl?: string, + ): Promise; - /** - * Deletes a specific user by its ID. - * - * @param id The ID of the user to delete. - */ - deleteUser(id: string): Promise; + /** + * Deletes a specific user by its ID. + * + * @param id The ID of the user to delete. + */ + deleteUser(id: string): Promise; - /** - * Renames a specific user by its ID. - * - * @param id The ID of the user to rename. - * @param newName The new name for the user. - */ - renameUser(id: string, newName: string): Promise; + /** + * Renames a specific user by its ID. + * + * @param id The ID of the user to rename. + * @param newName The new name for the user. + */ + renameUser(id: string, newName: string): Promise; } export default defineApiEndpoints((client, apiKey) => ({ - getUsers: async (id, name, email) => { - const moreThanOneFilter = - [id, name, email].filter((v) => v !== undefined).length > 1; + getUsers: async (id, name, email) => { + const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1; - if (moreThanOneFilter) { - throw new Error('Only one of id, name, or email filters can be provided'); - } + if (moreThanOneFilter) { + throw new Error("Only one of id, name, or email filters can be provided"); + } - const { users } = await client.apiFetch<{ users: User[] }>( - 'GET', - 'v1/user', - apiKey, - { id, name, email }, - ); + const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, { + id, + name, + email, + }); - return users; - }, + return users; + }, - createUser: async (username, email, displayName, pictureUrl) => { - const { user } = await client.apiFetch<{ user: User }>( - 'POST', - 'v1/user', - apiKey, - { name: username, email, displayName, pictureUrl }, - ); + createUser: async (username, email, displayName, pictureUrl) => { + const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, { + name: username, + email, + displayName, + pictureUrl, + }); - return user; - }, + return user; + }, - deleteUser: async (id) => { - await client.apiFetch('DELETE', `v1/user/${id}`, apiKey); - }, + deleteUser: async (id) => { + await client.apiFetch("DELETE", `v1/user/${id}`, apiKey); + }, - renameUser: async (oldId, newName) => { - await client.apiFetch( - 'POST', - `v1/user/${oldId}/rename/${newName}`, - apiKey, - ); - }, + renameUser: async (oldId, newName) => { + await client.apiFetch( + "POST", + `v1/user/${oldId}/rename/${encodeURIComponent(newName)}`, + apiKey, + ); + }, })); diff --git a/package.json b/package.json index 37be326..f1bde53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "headplane", - "version": "0.6.2", + "version": "0.6.3", "private": true, "type": "module", "sideEffects": false, diff --git a/tests/unit/headscale/api-paths.test.ts b/tests/unit/headscale/api-paths.test.ts new file mode 100644 index 0000000..4fed422 --- /dev/null +++ b/tests/unit/headscale/api-paths.test.ts @@ -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; + +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 ( + 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, + }, + ]); + }); +});