From aa7e2a3128765daa287666a749a13cb1a1b31a2e Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 11 Oct 2024 03:02:33 -0400 Subject: [PATCH] feat(TALE-35): implement pre-auth key management --- app/components/NumberField.tsx | 48 ++++ .../_data.machines._index/dialogs/new.tsx | 11 +- app/routes/_data.settings._index.tsx | 13 - app/routes/_data.settings._index/route.tsx | 39 +++ .../dialogs/expire.tsx | 69 ++++++ .../dialogs/new.tsx | 151 ++++++++++++ .../_data.settings.auth-keys._index/key.tsx | 55 +++++ .../_data.settings.auth-keys._index/route.tsx | 222 ++++++++++++++++++ app/types/PreAuthKey.ts | 11 + app/types/index.ts | 1 + pnpm-lock.yaml | 22 +- 11 files changed, 613 insertions(+), 29 deletions(-) create mode 100644 app/components/NumberField.tsx delete mode 100644 app/routes/_data.settings._index.tsx create mode 100644 app/routes/_data.settings._index/route.tsx create mode 100644 app/routes/_data.settings.auth-keys._index/dialogs/expire.tsx create mode 100644 app/routes/_data.settings.auth-keys._index/dialogs/new.tsx create mode 100644 app/routes/_data.settings.auth-keys._index/key.tsx create mode 100644 app/routes/_data.settings.auth-keys._index/route.tsx create mode 100644 app/types/PreAuthKey.ts diff --git a/app/components/NumberField.tsx b/app/components/NumberField.tsx new file mode 100644 index 0000000..f06981f --- /dev/null +++ b/app/components/NumberField.tsx @@ -0,0 +1,48 @@ +import { PlusIcon, DashIcon } from '@primer/octicons-react' +import { Dispatch, SetStateAction } from 'react' +import { + Button, + Group, + Input, + NumberField as AriaNumberField +} from 'react-aria-components' + +import { cn } from '~/utils/cn' + +type NumberFieldProps = Parameters[0] & { + label: string; + state?: [number, Dispatch>]; +} + +export default function NumberField(props: NumberFieldProps) { + return ( + { + props.state?.[1](value) + }} + > + + + + + + + ) +} diff --git a/app/routes/_data.machines._index/dialogs/new.tsx b/app/routes/_data.machines._index/dialogs/new.tsx index 3548cb9..dd35dc9 100644 --- a/app/routes/_data.machines._index/dialogs/new.tsx +++ b/app/routes/_data.machines._index/dialogs/new.tsx @@ -1,4 +1,4 @@ -import { Form, useFetcher } from '@remix-run/react' +import { Form, useFetcher, Link } from '@remix-run/react' import { Dispatch, SetStateAction, useState, useEffect } from 'react' import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react' import { cn } from '~/utils/cn' @@ -20,7 +20,6 @@ export interface NewProps { export default function New(data: NewProps) { const fetcher = useFetcher() const mkeyState = useState(false) - const pkeyState = useState(false) const [mkey, setMkey] = useState('') const [user, setUser] = useState('') const [toasted, setToasted] = useState(false) @@ -124,9 +123,11 @@ export default function New(data: NewProps) { Register Machine Key - - - Generate Pre-auth Key + + + + Generate Pre-auth Key + diff --git a/app/routes/_data.settings._index.tsx b/app/routes/_data.settings._index.tsx deleted file mode 100644 index 8cee111..0000000 --- a/app/routes/_data.settings._index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { IssueDraftIcon } from '@primer/octicons-react' - -export default function Page() { - return ( -
- -

- The settings page is currently unavailable. - It will be available in a future release. -

-
- ) -} diff --git a/app/routes/_data.settings._index/route.tsx b/app/routes/_data.settings._index/route.tsx new file mode 100644 index 0000000..05ae75a --- /dev/null +++ b/app/routes/_data.settings._index/route.tsx @@ -0,0 +1,39 @@ +import Link from '~/components/Link' +import Button from '~/components/Button' +import { Link as RemixLink } from '@remix-run/react' +import { ArrowRightIcon } from '@primer/octicons-react' +import { cn } from '~/utils/cn' + +export default function Page() { + return ( +
+
+

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 + +

+
+ + + Manage Auth Keys + + + +
+ ) +} diff --git a/app/routes/_data.settings.auth-keys._index/dialogs/expire.tsx b/app/routes/_data.settings.auth-keys._index/dialogs/expire.tsx new file mode 100644 index 0000000..d3f48d0 --- /dev/null +++ b/app/routes/_data.settings.auth-keys._index/dialogs/expire.tsx @@ -0,0 +1,69 @@ +import { useFetcher } from '@remix-run/react' +import type { PreAuthKey } from '~/types' +import { cn } from '~/utils/cn' + +import Dialog from '~/components/Dialog' +import Spinner from '~/components/Spinner' + +interface Props { + authKey: PreAuthKey +} + +export default function ExpireKey({ authKey }: Props) { + const fetcher = useFetcher() + + return ( + + + Expire Key + + + {close => ( + <> + + Expire auth key? + + { + fetcher.submit(e.currentTarget) + close() + }}> + + + + Expiring this authentication key will immediately + prevent it from being used to authenticate new devices. + {' '} + This action cannot be undone. + +
+ + Cancel + + + {fetcher.state === 'idle' + ? undefined + : ( + + )} + Expire + +
+
+ + )} +
+
+ ) +} diff --git a/app/routes/_data.settings.auth-keys._index/dialogs/new.tsx b/app/routes/_data.settings.auth-keys._index/dialogs/new.tsx new file mode 100644 index 0000000..3e74c97 --- /dev/null +++ b/app/routes/_data.settings.auth-keys._index/dialogs/new.tsx @@ -0,0 +1,151 @@ +import { RepoForkedIcon } from '@primer/octicons-react' +import { useFetcher } from '@remix-run/react' +import { useState } from 'react' + +import Dialog from '~/components/Dialog' +import TextField from '~/components/TextField' +import NumberField from '~/components/NumberField' +import Tooltip from '~/components/Tooltip' +import Select from '~/components/Select' +import Switch from '~/components/Switch' +import Link from '~/components/Link' +import Spinner from '~/components/Spinner' + +import { cn } from '~/utils/cn' +import { User } from '~/types' + +interface Props { + users: User[] +} + +// TODO: Tags +export default function AddPreAuthKey(data: Props) { + const fetcher = useFetcher() + const [user, setUser] = useState('') + const [reusable, setReusable] = useState(false) + const [ephemeral, setEphemeral] = useState(false) + const [aclTags, setAclTags] = useState([]) + const [expiry, setExpiry] = useState(90) + + return ( + + + Create pre-auth key + + + {close => ( + <> + + Generate auth key + + { + fetcher.submit(e.currentTarget) + close() + }}> + + User + + + Attach this key to a user + + + + Key Expiration + + + Set this key to expire between 1 and 90 days. + + +
+
+ + Reusable + + + Use this key to authenticate more than one device. + +
+ { setReusable(!reusable) }} + /> +
+ +
+
+ + Ephemeral + + + Devices authenticated with this key will + be automatically removed once they go offline. + {' '} + + Learn more + + +
+ { + setEphemeral(!ephemeral) + }} + /> +
+ +
+ + Cancel + + + {fetcher.state === 'idle' + ? undefined + : ( + + )} + Generate + +
+
+ + )} +
+
+ ) +} diff --git a/app/routes/_data.settings.auth-keys._index/key.tsx b/app/routes/_data.settings.auth-keys._index/key.tsx new file mode 100644 index 0000000..c11cc9e --- /dev/null +++ b/app/routes/_data.settings.auth-keys._index/key.tsx @@ -0,0 +1,55 @@ +import type { PreAuthKey } from '~/types' +import { toast } from '~/components/Toaster' + +import Code from '~/components/Code' +import Button from '~/components/Button' +import Attribute from '~/components/Attribute' +import ExpireKey from './dialogs/expire' + +interface Props { + authKey: PreAuthKey + server: string +} + +export default function AuthKeyRow({ authKey, server }: Props) { + const createdAt = new Date(authKey.createdAt).toLocaleString() + const expiration = new Date(authKey.expiration).toLocaleString() + + return ( +
+ + + + + + + +

+ To use this key, run the following command on your device: +

+ + tailscale up --login-server {server} --authkey {authKey.key} + +
+ {authKey.used || new Date(authKey.expiration) < new Date() + ? undefined + : ( + + )} + +
+
+ ) +} diff --git a/app/routes/_data.settings.auth-keys._index/route.tsx b/app/routes/_data.settings.auth-keys._index/route.tsx new file mode 100644 index 0000000..73e3bb5 --- /dev/null +++ b/app/routes/_data.settings.auth-keys._index/route.tsx @@ -0,0 +1,222 @@ +import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node' +import { useLoaderData } from '@remix-run/react' +import { useLiveData } from '~/utils/useLiveData' +import { getSession } from '~/utils/sessions' +import { Link as RemixLink } from '@remix-run/react' +import { PreAuthKey, User } from '~/types' +import { pull, post } from '~/utils/headscale' +import { loadContext } from '~/utils/config/headplane' +import { useState } from 'react' + +import Link from '~/components/Link' +import TableList from '~/components/TableList' +import Select from '~/components/Select' +import Switch from '~/components/Switch' + +import AddPreAuthKey from './dialogs/new' +import AuthKeyRow from './key' + +export async function action({ request }: ActionFunctionArgs) { + const session = await getSession(request.headers.get('Cookie')) + if (!session.has('hsApiKey')) { + return json({ message: 'Unauthorized' }, { + status: 401, + }) + } + + const data = await request.formData() + + // Expiring a pre-auth key + if (request.method === 'DELETE') { + const key = data.get('key') + const user = data.get('user') + + if (!key || !user) { + return json({ message: 'Missing parameters' }, { + status: 400, + }) + } + + await post<{ preAuthKey: PreAuthKey }>( + 'v1/preauthkey/expire', + session.get('hsApiKey')!, + { + user: user, + key: key, + } + ) + + return json({ message: 'Pre-auth key expired' }) + } + + // Creating a new pre-auth key + if (request.method === 'POST') { + const user = data.get('user') + const expiry = data.get('expiry') + const reusable = data.get('reusable') + const ephemeral = data.get('ephemeral') + + if (!user || !expiry || !reusable || !ephemeral) { + return json({ message: 'Missing parameters' }, { + status: 400, + }) + } + + // 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 date = new Date() + date.setDate(date.getDate() + day) + + const key = await post<{ preAuthKey: PreAuthKey }>( + 'v1/preauthkey', + session.get('hsApiKey')!, + { + user: user, + ephemeral: ephemeral === 'on', + reusable: reusable === 'on', + expiration: date.toISOString(), + aclTags: [], // TODO + } + ) + + return json({ message: 'Pre-auth key created', key }) + } +} + +export async function loader({ request }: LoaderFunctionArgs) { + const context = await loadContext() + const session = await getSession(request.headers.get('Cookie')) + const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!) + + const preAuthKeys = await Promise.all(users.users.map(user => { + const qp = new URLSearchParams() + qp.set('user', user.name) + + return pull<{ preAuthKeys: PreAuthKey[] }>( + `v1/preauthkey?${qp.toString()}`, + session.get('hsApiKey')! + ) + })) + + return { + keys: preAuthKeys.flatMap(keys => keys.preAuthKeys), + users: users.users, + server: context.headscaleUrl, + } +} + +export default function Page() { + const { keys, users, server } = useLoaderData() + const [user, setUser] = useState('All') + const [status, setStatus] = useState('Active') + + const filteredKeys = keys.filter(key => { + if (user !== 'All' && key.user !== user) { + return false + } + + if (status !== 'All') { + const now = new Date() + const expiry = new Date(key.expiration) + + if (status === 'Active') { + return !(expiry < now) && !key.used + } + + if (status === 'Used/Expired') { + return key.used || expiry < now + } + + if (status === 'Reusable') { + return key.reusable + } + + if (status === 'Ephemeral') { + return key.ephemeral + } + } + + return true + }) + + return ( +
+

+ + Settings + + + / + + {' '} + Pre-Auth Keys +

+

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 + +

+ +
+
+

+ Filter by user +

+ +
+
+

+ Filter by status +

+ +
+
+ + {filteredKeys.length === 0 ? ( + +

+ No pre-auth keys +

+
+ ) : filteredKeys.map(key => ( + + + + ))} +
+
+ ) +} diff --git a/app/types/PreAuthKey.ts b/app/types/PreAuthKey.ts new file mode 100644 index 0000000..04c456d --- /dev/null +++ b/app/types/PreAuthKey.ts @@ -0,0 +1,11 @@ +export interface PreAuthKey { + id: string + key: string + user: string + reusable: boolean + ephemeral: boolean + used: boolean + expiration: string + createdAt: string + aclTags: string[] +} diff --git a/app/types/index.ts b/app/types/index.ts index 08d36c3..1bd9b93 100644 --- a/app/types/index.ts +++ b/app/types/index.ts @@ -2,3 +2,4 @@ export * from './Key' export * from './Machine' export * from './Route' export * from './User' +export * from './PreAuthKey' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b40bab8..b0cb9d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,8 +689,8 @@ packages: '@humanwhocodes/object-schema@2.0.2': resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} - '@internationalized/date@3.5.4': - resolution: {integrity: sha512-qoVJVro+O0rBaw+8HPjUB1iH8Ihf8oziEnqMnvhJUSuVIrHOuZ6eNLHNvzXJKUvAtaDiqMnRlg8Z2mgh09BlUw==} + '@internationalized/date@3.5.6': + resolution: {integrity: sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==} '@internationalized/message@3.1.4': resolution: {integrity: sha512-Dygi9hH1s7V9nha07pggCkvmRfDd3q2lWnMGvrJyrOwYMe1yj4D2T9BoH9I6MGR7xz0biQrtLPsqUkqXzIrBOw==} @@ -5157,7 +5157,7 @@ snapshots: '@humanwhocodes/object-schema@2.0.2': {} - '@internationalized/date@3.5.4': + '@internationalized/date@3.5.6': dependencies: '@swc/helpers': 0.5.11 @@ -5343,7 +5343,7 @@ snapshots: '@react-aria/calendar@3.5.8(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704)': dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@react-aria/i18n': 3.11.1(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/interactions': 3.21.3(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/live-announcer': 3.3.4 @@ -5411,7 +5411,7 @@ snapshots: '@react-aria/datepicker@3.10.1(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704)': dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@internationalized/number': 3.5.3 '@internationalized/string': 3.2.3 '@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704) @@ -5513,7 +5513,7 @@ snapshots: '@react-aria/i18n@3.11.1(react@19.0.0-rc-f38c22b244-20240704)': dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@internationalized/message': 3.1.4 '@internationalized/number': 3.5.3 '@internationalized/string': 3.2.3 @@ -5879,7 +5879,7 @@ snapshots: '@react-stately/calendar@3.5.1(react@19.0.0-rc-f38c22b244-20240704)': dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@react-stately/utils': 3.10.1(react@19.0.0-rc-f38c22b244-20240704) '@react-types/calendar': 3.4.6(react@19.0.0-rc-f38c22b244-20240704) '@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704) @@ -5936,7 +5936,7 @@ snapshots: '@react-stately/datepicker@3.9.4(react@19.0.0-rc-f38c22b244-20240704)': dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@internationalized/string': 3.2.3 '@react-stately/form': 3.0.3(react@19.0.0-rc-f38c22b244-20240704) '@react-stately/overlays': 3.6.7(react@19.0.0-rc-f38c22b244-20240704) @@ -6122,7 +6122,7 @@ snapshots: '@react-types/calendar@3.4.6(react@19.0.0-rc-f38c22b244-20240704)': dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704) react: 19.0.0-rc-f38c22b244-20240704 @@ -6144,7 +6144,7 @@ snapshots: '@react-types/datepicker@3.7.4(react@19.0.0-rc-f38c22b244-20240704)': dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@react-types/calendar': 3.4.6(react@19.0.0-rc-f38c22b244-20240704) '@react-types/overlays': 3.8.7(react@19.0.0-rc-f38c22b244-20240704) '@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704) @@ -9205,7 +9205,7 @@ snapshots: react-aria-components@1.2.1(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704): dependencies: - '@internationalized/date': 3.5.4 + '@internationalized/date': 3.5.6 '@internationalized/string': 3.2.3 '@react-aria/color': 3.0.0-beta.33(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704)