import { FileKey2 } from "lucide-react"; import { useMemo, useState } from "react"; import Code from "~/components/Code"; import Link from "~/components/link"; 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 { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/overview"; import { authKeysAction } from "./actions"; import AuthKeyRow from "./auth-key-row"; import AddAuthKey from "./dialogs/add-auth-key"; export async function loader({ request, context }: Route.LoaderArgs) { const principal = await context.auth.require(request); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); const api = context.hsApi.getRuntimeClient(apiKey); const users = await api.getUsers(); 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(); 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({ preAuthKeys: tagOnly, user: null }); } for (const user of users) { const userKeys = keysByUser.get(user.id); if (userKeys?.length) { keys.push({ preAuthKeys: userKeys, user }); } } } else { type FetchResult = | { success: true; user: User; preAuthKeys: PreAuthKey[] } | { success: false; user: User; error: unknown; preAuthKeys: [] }; const results: FetchResult[] = await Promise.all( users .filter((u) => u.id?.length > 0) .map(async (user) => { try { const preAuthKeys = await api.getPreAuthKeys(user.id); return { preAuthKeys, success: true as const, user }; } catch (error) { log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error); return { error, preAuthKeys: [] as const, success: false as const, user }; } }), ); keys = results .filter(({ success }) => success) .map(({ user, preAuthKeys }) => ({ preAuthKeys, user })); missing = results .filter((r): r is Extract => !r.success) .map(({ user, error }) => ({ error, user })); } const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); return { access: canGenerateAny || canGenerateOwn, currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined, keys, missing, selfServiceOnly: !canGenerateAny && canGenerateOwn, url: context.config.headscale.public_url ?? context.config.headscale.url, users, }; } export const action = authKeysAction; type Status = "all" | "active" | "expired" | "reusable" | "ephemeral"; export default function Page({ loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject }, }: Route.ComponentProps) { const [selectedUser, setSelectedUser] = useState("__headplane_all"); const [status, setStatus] = useState("active"); const isDisabled = !access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0; const filteredKeys = useMemo(() => { const now = new Date(); return keys .filter(({ user }) => { if (selectedUser === "__headplane_all") { return true; } if (selectedUser === "__headplane_tag_only") { return user === null; } return user?.id === selectedUser; }) .flatMap(({ preAuthKeys }) => preAuthKeys) .filter((key) => { if (status === "all") { return true; } if (status === "ephemeral") { return key.ephemeral; } if (status === "reusable") { return key.reusable; } const expiry = new Date(key.expiration); if (status === "expired") { // Expired keys are either used or expired // BUT only used if they are not reusable if (key.used && !key.reusable) { return true; } return expiry < now; } if (status === "active") { // Active keys are either not expired or reusable if (expiry < now) { return false; } if (!key.used) { return true; } return key.reusable; } return false; }); }, [keys, selectedUser, status]); return (

Settings / Pre-Auth Keys

{!access ? ( You do not have the necessary permissions to generate pre-auth keys. Please contact your administrator to request access or to generate a pre-auth key for you. ) : missing.length > 0 ? ( An error occurred while fetching the authentication keys for the following users:{" "} {missing.map(({ user }, index) => ( <> {getUserDisplayName(user)} {index < missing.length - 1 ? ", " : ". "} ))} Their keys may not be listed correctly. Please check the server logs for more information. ) : undefined}

Pre-Auth Keys

Headscale fully supports pre-authentication keys in order to easily add devices to your Tailnet. To learn more about using pre-authentication keys, visit the{" "} Tailscale documentation

{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (

No pre-auth keys have been created yet.

) : filteredKeys.length === 0 ? (

No pre-auth keys match the selected filters.

) : ( filteredKeys.map((key) => { // Tag-only keys have no user if (!key.user) { return ( ); } // 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); if (!user) { return null; } return ( ); }) )}
); }