mirror of
https://github.com/tale/headplane.git
synced 2026-08-22 02:36:38 +00:00
Merge pull request #465 from drifterza/feature/preauth-key-tags
This commit is contained in:
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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@");
|
||||||
|
|||||||
Reference in New Issue
Block a user