Merge pull request #465 from drifterza/feature/preauth-key-tags

This commit is contained in:
Aarnav Tale
2026-02-26 00:04:02 -05:00
committed by GitHub
4 changed files with 147 additions and 87 deletions
+11 -6
View File
@@ -25,9 +25,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
switch (action) { switch (action) {
case "add_preauthkey": { case "add_preauthkey": {
const user = formData.get("user_id")?.toString(); const user = formData.get("user_id")?.toString() || null;
if (!user) { const aclTagsRaw = formData.get("acl_tags")?.toString() || "";
return data("Missing `user_id` in the form data.", { 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, 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 day = Number(expiry.toString().split(" ")[0]);
const date = new Date(); const date = new Date();
date.setDate(date.getDate() + day); date.setDate(date.getDate() + day);
const key = await api.createPreAuthKey( const key = await api.createPreAuthKey(
user, user,
ephemeral === "on", ephemeral === "on",
reusable === "on", reusable === "on",
date, date,
[], // TODO aclTags.length > 0 ? aclTags : null,
); );
return data({ success: true as const, key: key.key }); return data({ success: true as const, key: key.key });
@@ -6,6 +6,7 @@ import type { User } from "~/types";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Code from "~/components/Code"; import Code from "~/components/Code";
import Dialog from "~/components/Dialog"; import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import Link from "~/components/Link"; import Link from "~/components/Link";
import NumberInput from "~/components/NumberInput"; import NumberInput from "~/components/NumberInput";
import Select from "~/components/Select"; import Select from "~/components/Select";
@@ -23,7 +24,9 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false); const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false); const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false);
const [userId, setUserId] = useState<Key | null>(users[0]?.id); const [userId, setUserId] = useState<Key | null>(users[0]?.id);
const [tags, setTags] = useState("");
const createdKey = fetcher.data?.success ? fetcher.data.key : null; const createdKey = fetcher.data?.success ? fetcher.data.key : null;
@@ -37,11 +40,21 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
if (!isOpen) { if (!isOpen) {
setReusable(false); setReusable(false);
setEphemeral(false); setEphemeral(false);
setTagOnly(false);
setUserId(users[0]?.id); setUserId(users[0]?.id);
setTags("");
fetcher.data = undefined; fetcher.data = undefined;
} }
}, [isOpen]); }, [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 ( return (
<Dialog <Dialog
isOpen={isOpen} isOpen={isOpen}
@@ -84,30 +97,56 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
submittingRef.current = true; submittingRef.current = true;
const form = new FormData(event.currentTarget as HTMLFormElement); const form = new FormData(event.currentTarget as HTMLFormElement);
form.set("action_id", "add_preauthkey"); form.set("action_id", "add_preauthkey");
form.set("user_id", userId?.toString() ?? ""); form.set("user_id", tagOnly ? "" : (userId?.toString() ?? ""));
form.set("reusable", reusable ? "on" : "off"); form.set("reusable", reusable ? "on" : "off");
form.set("ephemeral", ephemeral ? "on" : "off"); form.set("ephemeral", ephemeral ? "on" : "off");
form.set("acl_tags", parsedTags.join(","));
fetcher.submit(form, { method: "POST" }); fetcher.submit(form, { method: "POST" });
}} }}
isDisabled={fetcher.state !== "idle"} isDisabled={fetcher.state !== "idle" || !canSubmit}
> >
<Dialog.Title>Generate auth key</Dialog.Title> <Dialog.Title>Generate auth key</Dialog.Title>
<Select
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Tag-only key</Dialog.Text>
<Dialog.Text className="text-sm">
Create a key owned by ACL tags instead of a user.
</Dialog.Text>
</div>
<Switch
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div>
{!tagOnly && (
<Select
className="mb-2"
description="Machines will belong to this user when they authenticate."
isRequired
label="User"
onSelectionChange={(value) => setUserId(value)}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
)}
<Input
className="mb-2" className="mb-2"
description="This is the user machines will belong to when they authenticate." description="Comma-separated tags (e.g. server, prod). The tag: prefix is added automatically."
isRequired isRequired={tagOnly}
label="User" label="ACL Tags"
onSelectionChange={(value) => { onChange={(value) => setTags(value)}
setUserId(value); placeholder="server, prod"
}} value={tags}
placeholder="Select a user" />
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
<NumberInput <NumberInput
defaultValue={90} defaultValue={90}
description="Set this key to expire after a certain number of days." description="Set this key to expire after a certain number of days."
@@ -132,9 +171,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
<Switch <Switch
defaultSelected={reusable} defaultSelected={reusable}
label="Reusable" label="Reusable"
onChange={() => { onChange={() => setReusable(!reusable)}
setReusable(!reusable);
}}
/> />
</div> </div>
<div className="mt-6 flex items-center justify-between gap-2"> <div className="mt-6 flex items-center justify-between gap-2">
@@ -154,9 +191,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
<Switch <Switch
defaultSelected={ephemeral} defaultSelected={ephemeral}
label="Ephemeral" label="Ephemeral"
onChange={() => { onChange={() => setEphemeral(!ephemeral)}
setEphemeral(!ephemeral);
}}
/> />
</div> </div>
</Dialog.Panel> </Dialog.Panel>
@@ -1,69 +1,72 @@
import type { PreAuthKey } from '~/types'; import type { PreAuthKey } from "~/types";
import { defineApiEndpoints } from '../factory';
import { defineApiEndpoints } from "../factory";
export interface PreAuthKeyEndpoints { export interface PreAuthKeyEndpoints {
/** /**
* Retrieves all pre-authentication keys for a specific user. * Retrieves all pre-authentication keys for a specific user.
* *
* @param user The user to retrieve pre-authentication keys for. * @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys. * @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/ */
getPreAuthKeys(user: string): Promise<PreAuthKey[]>; getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/** /**
* Creates a new pre-authentication key for a specific user. * Creates a new pre-authentication key.
* * User can be null for tag-only keys (requires Headscale 0.28+).
* @param user The user to create the pre-authentication key for. */
* @param ephemeral Whether the key is ephemeral. createPreAuthKey(
* @param reusable Whether the key is reusable. user: string | null,
* @param expiration The expiration date of the key, or `null` for no expiration. ephemeral: boolean,
* @param aclTags An array of ACL tags to associate with the key, or `null` for none. reusable: boolean,
* @returns A `PreAuthKey` object representing the newly created pre-authentication key. expiration: Date | null,
*/ aclTags: string[] | null,
createPreAuthKey( ): Promise<PreAuthKey>;
user: string,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/** /**
* Expires a specific pre-authentication key for a user. * Expires a specific pre-authentication key for a user.
* *
* @param user The user associated with the pre-authentication key. * @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire. * @param key The pre-authentication key to expire.
*/ */
expirePreAuthKey(user: string, key: string): Promise<void>; expirePreAuthKey(user: string, key: string): Promise<void>;
} }
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({ export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getPreAuthKeys: async (user) => { getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{ const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[]; preAuthKeys: PreAuthKey[];
}>('GET', 'v1/preauthkey', apiKey, { user }); }>("GET", "v1/preauthkey", apiKey, { user });
return preAuthKeys; return preAuthKeys;
}, },
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => { createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const { preAuthKey } = await client.apiFetch<{ const body: Record<string, unknown> = {
preAuthKey: PreAuthKey; ephemeral,
}>('POST', 'v1/preauthkey', apiKey, { reusable,
user, expiration: expiration ? expiration.toISOString() : null,
ephemeral, };
reusable,
expiration: expiration ? expiration.toISOString() : null,
aclTags,
});
return preAuthKey; if (user) {
}, body.user = user;
}
expirePreAuthKey: async (user, key) => { if (aclTags && aclTags.length > 0) {
await client.apiFetch<void>('POST', 'v1/preauthkey/expire', apiKey, { body.aclTags = aclTags;
user, }
key,
}); const { preAuthKey } = await client.apiFetch<{
}, preAuthKey: PreAuthKey;
}>("POST", "v1/preauthkey", apiKey, body);
return preAuthKey;
},
expirePreAuthKey: async (user, key) => {
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key,
});
},
})); }));
+18 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; 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) => { describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => { 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()); 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 () => { test("pre-auth keys can be listed", async () => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@"); const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");