Merge pull request #466 from drifterza/feature/list-all-preauth-keys

This commit is contained in:
Aarnav Tale
2026-02-26 00:47:07 -05:00
committed by GitHub
5 changed files with 143 additions and 43 deletions
@@ -6,23 +6,26 @@ import ExpireAuthKey from "./dialogs/expire-auth-key";
interface Props { interface Props {
authKey: PreAuthKey; authKey: PreAuthKey;
user: User; user: User | null;
} }
export default function AuthKeyRow({ authKey, user }: Props) { export default function AuthKeyRow({ authKey, user }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString(); const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString(); const expiration = new Date(authKey.expiration).toLocaleString();
const isExpired =
(authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date();
const userDisplay = user ? user.name || user.displayName || user.email || user.id : "(Tag Only)";
return ( return (
<div className="w-full"> <div className="w-full">
<Attribute name="Key" value={authKey.key} /> <Attribute name="Key" value={authKey.key} />
<Attribute name="User" value={user.name || user.displayName || user.email || user.id} /> <Attribute name="User" value={userDisplay} />
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} /> <Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} /> <Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} /> <Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
<Attribute name="Created" value={createdAt} /> <Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} /> <Attribute name="Expiration" value={expiration} />
{!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && ( {!isExpired && user && (
<div className="mt-2" suppressHydrationWarning> <div className="mt-2" suppressHydrationWarning>
<ExpireAuthKey authKey={authKey} user={user} /> <ExpireAuthKey authKey={authKey} user={user} />
</div> </div>
+79 -36
View File
@@ -8,6 +8,8 @@ import Notice from "~/components/Notice";
import Select from "~/components/Select"; import Select from "~/components/Select";
import TableList from "~/components/TableList"; import TableList from "~/components/TableList";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import log from "~/utils/log"; import log from "~/utils/log";
import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user"; import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user";
@@ -22,40 +24,63 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const users = await api.getUsers(); const users = await api.getUsers();
const preAuthKeys = await Promise.all(
filterUsersWithValidIds(users).map(async (user) => { let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
try { let missing: { user: User; error: unknown }[] = [];
const preAuthKeys = await api.getPreAuthKeys(user.id);
return { // Try fetching all keys at once (Headscale 0.28+), fall back to per-user
success: true, let allKeys: PreAuthKey[] | null = null;
user, try {
preAuthKeys, allKeys = await api.getAllPreAuthKeys();
}; } catch {
} catch (error) { // older versions don't support this endpoint
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error); }
return {
success: false, if (allKeys !== null) {
user, const keysByUser = new Map<string | null, PreAuthKey[]>();
error, for (const key of allKeys) {
preAuthKeys: [], const userId = key.user?.id ?? null;
}; const existing = keysByUser.get(userId) ?? [];
existing.push(key);
keysByUser.set(userId, existing);
}
keys = [];
const tagOnly = keysByUser.get(null);
if (tagOnly?.length) {
keys.push({ user: null, preAuthKeys: tagOnly });
}
for (const user of users) {
const userKeys = keysByUser.get(user.id);
if (userKeys?.length) {
keys.push({ user, preAuthKeys: userKeys });
} }
}), }
); } else {
type FetchResult =
| { success: true; user: User; preAuthKeys: PreAuthKey[] }
| { success: false; user: User; error: unknown; preAuthKeys: [] };
const keys = preAuthKeys const results: FetchResult[] = await Promise.all(
.filter(({ success }) => success) filterUsersWithValidIds(users).map(async (user) => {
.map(({ user, preAuthKeys }) => ({ try {
user, const preAuthKeys = await api.getPreAuthKeys(user.id);
preAuthKeys, return { success: true as const, user, preAuthKeys };
})); } catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return { success: false as const, user, error, preAuthKeys: [] as const };
}
}),
);
const missing = preAuthKeys keys = results
.filter(({ success }) => !success) .filter(({ success }) => success)
.map(({ user, error }) => ({ .map(({ user, preAuthKeys }) => ({ user, preAuthKeys }));
user,
error, missing = results
})); .filter((r): r is Extract<FetchResult, { success: false }> => !r.success)
.map(({ user, error }) => ({ user, error }));
}
return { return {
keys, keys,
@@ -84,7 +109,11 @@ export default function Page({
return true; return true;
} }
return user.id === selectedUser; if (selectedUser === "__headplane_tag_only") {
return user === null;
}
return user?.id === selectedUser;
}) })
.flatMap(({ preAuthKeys }) => preAuthKeys) .flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => { .filter((key) => {
@@ -176,9 +205,14 @@ export default function Page({
> >
{[ {[
<Select.Item key="__headplane_all">All</Select.Item>, <Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => ( ...keys
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item> .filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null)
)), .map(({ user }) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
)),
...(keys.some(({ user }) => user === null)
? [<Select.Item key="__headplane_tag_only">Tag Only</Select.Item>]
: []),
]} ]}
</Select> </Select>
<Select <Select
@@ -209,9 +243,18 @@ export default function Page({
</TableList.Item> </TableList.Item>
) : ( ) : (
filteredKeys.map((key) => { filteredKeys.map((key) => {
// Tag-only keys have no user
if (!key.user) {
return (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} user={null} />
</TableList.Item>
);
}
// TODO: Why is Headscale using email as the user ID here? // TODO: Why is Headscale using email as the user ID here?
// https://github.com/juanfont/headscale/issues/2520 // https://github.com/juanfont/headscale/issues/2520
const user = users.find((user) => user.id === key.user.id); const user = users.find((user) => user.id === key.user?.id);
if (!user) { if (!user) {
return null; return null;
} }
@@ -3,6 +3,11 @@ import type { PreAuthKey } from "~/types";
import { defineApiEndpoints } from "../factory"; import { defineApiEndpoints } from "../factory";
export interface PreAuthKeyEndpoints { export interface PreAuthKeyEndpoints {
/**
* List all pre-auth keys. Requires Headscale 0.28+.
*/
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
/** /**
* Retrieves all pre-authentication keys for a specific user. * Retrieves all pre-authentication keys for a specific user.
* *
@@ -33,6 +38,14 @@ export interface PreAuthKeyEndpoints {
} }
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({ export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getAllPreAuthKeys: async () => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, {});
return preAuthKeys;
},
getPreAuthKeys: async (user) => { getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{ const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[]; preAuthKeys: PreAuthKey[];
+1 -1
View File
@@ -3,7 +3,7 @@ import type { User } from './User';
export interface PreAuthKey { export interface PreAuthKey {
id: string; id: string;
key: string; key: string;
user: User; user: User | null;
reusable: boolean; reusable: boolean;
ephemeral: boolean; ephemeral: boolean;
used: boolean; used: boolean;
+44 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS } from "./setup/env"; import { getBootstrapClient, getIsAtLeast, 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 () => {
@@ -13,7 +13,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null); const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
expect(preAuthKey).toBeDefined(); expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id); expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(false); expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(false); expect(preAuthKey.reusable).toBe(false);
expect(preAuthKey.aclTags).toEqual([]); expect(preAuthKey.aclTags).toEqual([]);
@@ -30,7 +30,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags); const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
expect(preAuthKey).toBeDefined(); expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id); expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(true); expect(preAuthKey.ephemeral).toBe(true);
expect(preAuthKey.reusable).toBe(true); expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort()); expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
@@ -64,6 +64,47 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2); expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
}); });
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
const allKeys = await client.getAllPreAuthKeys();
expect(Array.isArray(allKeys)).toBe(true);
expect(allKeys.length).toBeGreaterThanOrEqual(2);
const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
for (const userKey of userSpecificKeys) {
const found = allKeys.find((k) => k.key === userKey.key);
expect(found).toBeDefined();
}
});
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const allKeys = await client.getAllPreAuthKeys();
for (const key of allKeys) {
expect(key.id).toBeDefined();
expect(key.key).toBeDefined();
expect(typeof key.reusable).toBe("boolean");
expect(typeof key.ephemeral).toBe("boolean");
expect(typeof key.used).toBe("boolean");
expect(key.expiration).toBeDefined();
expect(key.createdAt).toBeDefined();
}
});
test("pre-auth keys can be expired", async () => { test("pre-auth keys can be expired", 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@");