mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +00:00
Merge pull request #466 from drifterza/feature/list-all-preauth-keys
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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@");
|
||||||
|
|||||||
Reference in New Issue
Block a user