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 {
authKey: PreAuthKey;
user: User;
user: User | null;
}
export default function AuthKeyRow({ authKey, user }: Props) {
const createdAt = new Date(authKey.createdAt).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 (
<div className="w-full">
<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="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
{!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && (
{!isExpired && user && (
<div className="mt-2" suppressHydrationWarning>
<ExpireAuthKey authKey={authKey} user={user} />
</div>
+79 -36
View File
@@ -8,6 +8,8 @@ import Notice from "~/components/Notice";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import log from "~/utils/log";
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 users = await api.getUsers();
const preAuthKeys = await Promise.all(
filterUsersWithValidIds(users).map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return {
success: true,
user,
preAuthKeys,
};
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return {
success: false,
user,
error,
preAuthKeys: [],
};
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
let missing: { user: User; error: unknown }[] = [];
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null;
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// older versions don't support this endpoint
}
if (allKeys !== null) {
const keysByUser = new Map<string | null, PreAuthKey[]>();
for (const key of allKeys) {
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
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({
user,
preAuthKeys,
}));
const results: FetchResult[] = await Promise.all(
filterUsersWithValidIds(users).map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
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
.filter(({ success }) => !success)
.map(({ user, error }) => ({
user,
error,
}));
keys = results
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({ user, preAuthKeys }));
missing = results
.filter((r): r is Extract<FetchResult, { success: false }> => !r.success)
.map(({ user, error }) => ({ user, error }));
}
return {
keys,
@@ -84,7 +109,11 @@ export default function Page({
return true;
}
return user.id === selectedUser;
if (selectedUser === "__headplane_tag_only") {
return user === null;
}
return user?.id === selectedUser;
})
.flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => {
@@ -176,9 +205,14 @@ export default function Page({
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
)),
...keys
.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
@@ -209,9 +243,18 @@ export default function Page({
</TableList.Item>
) : (
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?
// 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) {
return null;
}
@@ -3,6 +3,11 @@ import type { PreAuthKey } from "~/types";
import { defineApiEndpoints } from "../factory";
export interface PreAuthKeyEndpoints {
/**
* List all pre-auth keys. Requires Headscale 0.28+.
*/
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
/**
* Retrieves all pre-authentication keys for a specific user.
*
@@ -33,6 +38,14 @@ export interface PreAuthKeyEndpoints {
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getAllPreAuthKeys: async () => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, {});
return preAuthKeys;
},
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
+1 -1
View File
@@ -3,7 +3,7 @@ import type { User } from './User';
export interface PreAuthKey {
id: string;
key: string;
user: User;
user: User | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
+44 -3
View File
@@ -1,6 +1,6 @@
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) => {
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);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(false);
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);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(true);
expect(preAuthKey.reusable).toBe(true);
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);
});
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 () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");