feat(TALE-35): implement pre-auth key management

This commit is contained in:
Aarnav Tale 2024-10-11 03:02:33 -04:00
parent ecef45c98a
commit aa7e2a3128
No known key found for this signature in database
11 changed files with 613 additions and 29 deletions

View File

@ -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<typeof AriaNumberField>[0] & {
label: string;
state?: [number, Dispatch<SetStateAction<number>>];
}
export default function NumberField(props: NumberFieldProps) {
return (
<AriaNumberField
{...props}
aria-label={props.label}
className="w-full"
value={props.state?.[0]}
onChange={value => {
props.state?.[1](value)
}}
>
<Group className={cn(
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 gap-2',
'focus-within:ring-2 focus-within:ring-blue-600',
props.className
)}>
<Input
className="w-full bg-transparent focus:outline-none"
name={props.name}
/>
<Button slot="decrement">
<DashIcon className="w-4 h-4" />
</Button>
<Button slot="increment">
<PlusIcon className="w-4 h-4" />
</Button>
</Group>
</AriaNumberField>
)
}

View File

@ -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) {
<ServerIcon className='w-4 h-4 mr-2'/>
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton control={pkeyState} isDisabled>
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
<Menu.ItemButton>
<Link to="/settings/auth-keys">
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>

View File

@ -1,13 +0,0 @@
import { IssueDraftIcon } from '@primer/octicons-react'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='text-lg mt-8'>
The settings page is currently unavailable.
It will be available in a future release.
</p>
</div>
)
}

View File

@ -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 (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300">
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
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink
to="/settings/auth-keys"
name="Auth Keys"
>
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}>
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
</span>
</RemixLink>
</div>
)
}

View File

@ -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 (
<Dialog>
<Dialog.Button className="my-4">
Expire Key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Expire auth key?
</Dialog.Title>
<fetcher.Form method="DELETE" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately
prevent it from being used to authenticate new devices.
{' '}
This action cannot be undone.
</Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white',
)}
onPress={close}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -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 (
<Dialog>
<Dialog.Button className="my-4">
Create pre-auth key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Generate auth key
</Dialog.Title>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<Dialog.Text className="font-semibold">
User
</Dialog.Text>
<Dialog.Text className="text-sm">
Attach this key to a user
</Dialog.Text>
<Select
label="Owner"
name="user"
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<Dialog.Text className="font-semibold mt-4">
Key Expiration
</Dialog.Text>
<Dialog.Text className="text-sm">
Set this key to expire between 1 and 90 days.
</Dialog.Text>
<NumberField
label="Expiry"
name="expiry"
minValue={1}
maxValue={90}
state={[expiry, setExpiry]}
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
/>
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Reusable
</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => { setReusable(!reusable) }}
/>
</div>
<input type="hidden" name="reusable" value={reusable} />
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Ephemeral
</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will
be automatically removed once they go offline.
{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
label="Ephemeral"
name="ephemeral"
defaultSelected={ephemeral}
onChange={() => {
setEphemeral(!ephemeral)
}}
/>
</div>
<input type="hidden" name="ephemeral" value={ephemeral} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={!user || !expiry}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Generate
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -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 (
<div className="w-full">
<Attribute name="Key" value={authKey.key} isCopyable />
<Attribute name="User" value={authKey.user} isCopyable />
<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} />
<p className="mb-1 mt-4">
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server {server} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
{authKey.used || new Date(authKey.expiration) < new Date()
? undefined
: (
<ExpireKey authKey={authKey} />
)}
<Button
variant="light"
className="my-4"
onClick={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server ${server} --authkey ${authKey.key}`
)
toast('Copied command to clipboard')
}}
>
Copy Tailscale Command
</Button>
</div>
</div>
)
}

View File

@ -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<typeof loader>()
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 (
<div className='flex flex-col w-2/3'>
<p className="mb-8 text-md">
<RemixLink
to="/settings"
className="font-medium"
>
Settings
</RemixLink>
<span className="mx-2">
/
</span>
{' '}
Pre-Auth Keys
</p>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
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
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
<AddPreAuthKey users={users} />
<div className="flex justify-between gap-4 mt-4">
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by user
</p>
<Select
label="Filter by User"
placeholder="Select a user"
state={[user, setUser]}
>
<Select.Item id="All">All</Select.Item>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
</div>
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by status
</p>
<Select
label="Filter by status"
placeholder="Select a status"
state={[status, setStatus]}
>
<Select.Item id="All">All</Select.Item>
<Select.Item id="Active">Active</Select.Item>
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
<Select.Item id="Reusable">Reusable</Select.Item>
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
</Select>
</div>
</div>
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No pre-auth keys
</p>
</TableList.Item>
) : filteredKeys.map(key => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))}
</TableList>
</div>
)
}

11
app/types/PreAuthKey.ts Normal file
View File

@ -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[]
}

View File

@ -2,3 +2,4 @@ export * from './Key'
export * from './Machine'
export * from './Route'
export * from './User'
export * from './PreAuthKey'

22
pnpm-lock.yaml generated
View File

@ -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)