From c96249f41e839f1f3074d8387dbf9bedbf72bfac Mon Sep 17 00:00:00 2001 From: drifterza Date: Tue, 24 Feb 2026 10:25:51 +0200 Subject: [PATCH] add tag-only preauth keys for headscale 0.28 allows creating keys with acl tags but no user --- app/routes/settings/auth-keys/actions.ts | 17 ++- .../auth-keys/dialogs/add-auth-key.tsx | 81 ++++++++---- .../headscale/api/endpoints/pre-auth-keys.ts | 117 +++++++++--------- tests/integration/pre-auth-keys.test.ts | 19 ++- 4 files changed, 147 insertions(+), 87 deletions(-) diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index 9e71783..30e3e83 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -25,9 +25,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { switch (action) { case "add_preauthkey": { - const user = formData.get("user_id")?.toString(); - if (!user) { - return data("Missing `user_id` in the form data.", { + const user = formData.get("user_id")?.toString() || null; + const aclTagsRaw = formData.get("acl_tags")?.toString() || ""; + const aclTags = aclTagsRaw + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + if (!user && aclTags.length === 0) { + return data("Must specify either a user or ACL tags.", { status: 400, }); } @@ -53,17 +59,16 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { }); } - // Extract the first "word" from expiry which is the day number - // Calculate the date X days from now using the day number const day = Number(expiry.toString().split(" ")[0]); const date = new Date(); date.setDate(date.getDate() + day); + const key = await api.createPreAuthKey( user, ephemeral === "on", reusable === "on", date, - [], // TODO + aclTags.length > 0 ? aclTags : null, ); return data({ success: true as const, key: key.key }); diff --git a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx index b30f8de..6e73523 100644 --- a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx +++ b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx @@ -6,6 +6,7 @@ import type { User } from "~/types"; import Button from "~/components/Button"; import Code from "~/components/Code"; import Dialog from "~/components/Dialog"; +import Input from "~/components/Input"; import Link from "~/components/Link"; import NumberInput from "~/components/NumberInput"; import Select from "~/components/Select"; @@ -23,7 +24,9 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) { const [isOpen, setIsOpen] = useState(false); const [reusable, setReusable] = useState(false); const [ephemeral, setEphemeral] = useState(false); + const [tagOnly, setTagOnly] = useState(false); const [userId, setUserId] = useState(users[0]?.id); + const [tags, setTags] = useState(""); const createdKey = fetcher.data?.success ? fetcher.data.key : null; @@ -37,11 +40,21 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) { if (!isOpen) { setReusable(false); setEphemeral(false); + setTagOnly(false); setUserId(users[0]?.id); + setTags(""); fetcher.data = undefined; } }, [isOpen]); + const parsedTags = tags + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .map((t) => (t.startsWith("tag:") ? t : `tag:${t}`)); + + const canSubmit = tagOnly ? parsedTags.length > 0 : userId != null; + return ( Generate auth key - setUserId(value)} + placeholder="Select a user" + > + {users.map((user) => ( + + {user.name || user.displayName || user.email || user.id} + + ))} + + )} + + { - setUserId(value); - }} - placeholder="Select a user" - > - {users.map((user) => ( - - {user.name || user.displayName || user.email || user.id} - - ))} - + description="Comma-separated tags (e.g. server, prod). The tag: prefix is added automatically." + isRequired={tagOnly} + label="ACL Tags" + onChange={(value) => setTags(value)} + placeholder="server, prod" + value={tags} + /> { - setReusable(!reusable); - }} + onChange={() => setReusable(!reusable)} />
@@ -154,9 +191,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) { { - setEphemeral(!ephemeral); - }} + onChange={() => setEphemeral(!ephemeral)} />
diff --git a/app/server/headscale/api/endpoints/pre-auth-keys.ts b/app/server/headscale/api/endpoints/pre-auth-keys.ts index 25c1658..e715070 100644 --- a/app/server/headscale/api/endpoints/pre-auth-keys.ts +++ b/app/server/headscale/api/endpoints/pre-auth-keys.ts @@ -1,69 +1,72 @@ -import type { PreAuthKey } from '~/types'; -import { defineApiEndpoints } from '../factory'; +import type { PreAuthKey } from "~/types"; + +import { defineApiEndpoints } from "../factory"; export interface PreAuthKeyEndpoints { - /** - * Retrieves all pre-authentication keys for a specific user. - * - * @param user The user to retrieve pre-authentication keys for. - * @returns An array of `PreAuthKey` objects representing the pre-authentication keys. - */ - getPreAuthKeys(user: string): Promise; + /** + * Retrieves all pre-authentication keys for a specific user. + * + * @param user The user to retrieve pre-authentication keys for. + * @returns An array of `PreAuthKey` objects representing the pre-authentication keys. + */ + getPreAuthKeys(user: string): Promise; - /** - * Creates a new pre-authentication key for a specific user. - * - * @param user The user to create the pre-authentication key for. - * @param ephemeral Whether the key is ephemeral. - * @param reusable Whether the key is reusable. - * @param expiration The expiration date of the key, or `null` for no expiration. - * @param aclTags An array of ACL tags to associate with the key, or `null` for none. - * @returns A `PreAuthKey` object representing the newly created pre-authentication key. - */ - createPreAuthKey( - user: string, - ephemeral: boolean, - reusable: boolean, - expiration: Date | null, - aclTags: string[] | null, - ): Promise; + /** + * Creates a new pre-authentication key. + * User can be null for tag-only keys (requires Headscale 0.28+). + */ + createPreAuthKey( + user: string | null, + ephemeral: boolean, + reusable: boolean, + expiration: Date | null, + aclTags: string[] | null, + ): Promise; - /** - * Expires a specific pre-authentication key for a user. - * - * @param user The user associated with the pre-authentication key. - * @param key The pre-authentication key to expire. - */ - expirePreAuthKey(user: string, key: string): Promise; + /** + * Expires a specific pre-authentication key for a user. + * + * @param user The user associated with the pre-authentication key. + * @param key The pre-authentication key to expire. + */ + expirePreAuthKey(user: string, key: string): Promise; } export default defineApiEndpoints((client, apiKey) => ({ - getPreAuthKeys: async (user) => { - const { preAuthKeys } = await client.apiFetch<{ - preAuthKeys: PreAuthKey[]; - }>('GET', 'v1/preauthkey', apiKey, { user }); + getPreAuthKeys: async (user) => { + const { preAuthKeys } = await client.apiFetch<{ + preAuthKeys: PreAuthKey[]; + }>("GET", "v1/preauthkey", apiKey, { user }); - return preAuthKeys; - }, + return preAuthKeys; + }, - createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => { - const { preAuthKey } = await client.apiFetch<{ - preAuthKey: PreAuthKey; - }>('POST', 'v1/preauthkey', apiKey, { - user, - ephemeral, - reusable, - expiration: expiration ? expiration.toISOString() : null, - aclTags, - }); + createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => { + const body: Record = { + ephemeral, + reusable, + expiration: expiration ? expiration.toISOString() : null, + }; - return preAuthKey; - }, + if (user) { + body.user = user; + } - expirePreAuthKey: async (user, key) => { - await client.apiFetch('POST', 'v1/preauthkey/expire', apiKey, { - user, - key, - }); - }, + if (aclTags && aclTags.length > 0) { + body.aclTags = aclTags; + } + + const { preAuthKey } = await client.apiFetch<{ + preAuthKey: PreAuthKey; + }>("POST", "v1/preauthkey", apiKey, body); + + return preAuthKey; + }, + + expirePreAuthKey: async (user, key) => { + await client.apiFetch("POST", "v1/preauthkey/expire", apiKey, { + user, + key, + }); + }, })); diff --git a/tests/integration/pre-auth-keys.test.ts b/tests/integration/pre-auth-keys.test.ts index bb58957..ab92877 100644 --- a/tests/integration/pre-auth-keys.test.ts +++ b/tests/integration/pre-auth-keys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getRuntimeClient, HS_VERSIONS } from "./setup/env"; +import { getBootstrapClient, getRuntimeClient, HS_VERSIONS } from "./setup/env"; describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => { test("pre-auth keys can be created", async () => { @@ -36,6 +36,23 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort()); }); + test("tag-only pre-auth keys (0.28+)", async (context) => { + const bootstrap = await getBootstrapClient(version); + if (!bootstrap.clientHelpers.isAtleast("0.28.0")) { + context.skip(); + } + + const client = await getRuntimeClient(version); + const aclTags = ["tag:server", "tag:prod"]; + const preAuthKey = await client.createPreAuthKey(null, false, true, null, aclTags); + + expect(preAuthKey).toBeDefined(); + expect(preAuthKey.user).toBeNull(); + expect(preAuthKey.ephemeral).toBe(false); + expect(preAuthKey.reusable).toBe(true); + expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort()); + }); + test("pre-auth keys can be listed", async () => { const client = await getRuntimeClient(version); const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");