Compare commits

...

8 Commits

Author SHA1 Message Date
Aarnav Tale 447a31f91e chore: v0.3.2 2024-10-11 03:09:56 -04:00
Aarnav Tale 9e21823163 chore: mention that settings is incomplete 2024-10-11 03:04:45 -04:00
Aarnav Tale aa7e2a3128 feat(TALE-35): implement pre-auth key management 2024-10-11 03:02:33 -04:00
Aarnav Tale ecef45c98a fix(TALE-35): use the correct machine registry endpoint 2024-10-10 11:12:35 -04:00
Aarnav Tale d165264876 fix: handle nullable expiry values from a db 2024-10-10 10:41:41 -04:00
Aarnav Tale dd479d4117 fix: remove older references to magic_dns via usernames 2024-10-10 10:03:43 -04:00
Akira Yamazaki 29d91785fb fix: build oidc callback url in a more proper way (#28) 2024-10-07 23:23:31 -04:00
Aarnav Tale 98d1cb1333 docs: mention 0.3.1 as the latest 2024-10-03 15:36:20 -04:00
24 changed files with 705 additions and 146 deletions
+7
View File
@@ -1,3 +1,10 @@
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024) ### 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously. - Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page. - Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
+48
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>
)
}
+8 -4
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react' import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute' import Attribute from '~/components/Attribute'
import Card from '~/components/Card' import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle'
import { type Machine, Route, User } from '~/types' import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale' import { loadConfig } from '~/utils/config/headscale'
@@ -56,6 +56,7 @@ export default function Page() {
const expired = machine.expiry === '0001-01-01 00:00:00' const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z' || machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false ? false
: new Date(machine.expiry).getTime() < Date.now() : new Date(machine.expiry).getTime() < Date.now()
@@ -134,14 +135,17 @@ export default function Page() {
/> />
<Attribute <Attribute
name="Expiry" name="Expiry"
value={new Date(machine.expiry).toLocaleString()} value={expired
? new Date(machine.expiry).toLocaleString()
: 'Never'
}
/> />
{magic {magic
? ( ? (
<Attribute <Attribute
isCopyable isCopyable
name="Domain" name="Domain"
value={`${machine.givenName}.${machine.user.name}.${magic}`} value={`${machine.givenName}.${magic}`}
/> />
) )
: undefined} : undefined}
+14 -3
View File
@@ -114,13 +114,24 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
} }
try { try {
await post('v1/node/register', session.get('hsApiKey')!, { const qp = new URLSearchParams()
qp.append('user', user)
qp.append('key', key)
const url = `v1/node/register?${qp.toString()}`
await post(url, session.get('hsApiKey')!, {
user, key, user, key,
}) })
return json({ message: 'Machine registered' }) return json({
success: true,
message: 'Machine registered'
})
} catch { } catch {
return json({ message: 'Failed to register machine' }, { return json({
success: false,
message: 'Failed to register machine'
}, {
status: 500, status: 500,
}) })
} }
@@ -29,8 +29,6 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
The owner of the machine is the user associated with it. The owner of the machine is the user associated with it.
When MagicDNS is enabled, the username of the owner
will control the hostname of the machine.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
@@ -54,46 +52,16 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
</Select> </Select>
{magic {magic
? ( ? (
owner === machine.user.name <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
? ( This machine is accessible by the hostname
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight"> {' '}
This machine is accessible by the hostname <Code className="text-sm">
{' '} {machine.givenName}
<Code className="text-sm"> .
{machine.givenName} {magic}
. </Code>
{owner} .
. </p>
{magic}
</Code>
.
</p>
)
: (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{owner}
.
{magic}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{machine.user.name}
.
{magic}
</Code>
{' '}
will no longer point to this machine.
</p>
)
) )
: undefined} : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
@@ -1,5 +1,5 @@
import { Form, useSubmit } from '@remix-run/react' import { Form, useFetcher, Link } from '@remix-run/react'
import { Dispatch, SetStateAction, useState } from 'react' import { Dispatch, SetStateAction, useState, useEffect } from 'react'
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react' import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
@@ -8,6 +8,8 @@ import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField' import TextField from '~/components/TextField'
import Select from '~/components/Select' import Select from '~/components/Select'
import Menu from '~/components/Menu' import Menu from '~/components/Menu'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types' import { Machine, User } from '~/types'
export interface NewProps { export interface NewProps {
@@ -16,11 +18,25 @@ export interface NewProps {
} }
export default function New(data: NewProps) { export default function New(data: NewProps) {
const submit = useSubmit() const fetcher = useFetcher()
const mkeyState = useState(false) const mkeyState = useState(false)
const pkeyState = useState(false)
const [mkey, setMkey] = useState('') const [mkey, setMkey] = useState('')
const [user, setUser] = useState(data.users[0].id) const [user, setUser] = useState('')
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Registered new machine')
} else {
toast('Failed to register machine due to an invalid key')
}
setToasted(true)
}, [fetcher.data, toasted, mkey])
return ( return (
<> <>
@@ -43,17 +59,15 @@ export default function New(data: NewProps) {
{' '} {' '}
on your device. on your device.
</Dialog.Text> </Dialog.Text>
<Form <fetcher.Form method="POST" onSubmit={e => {
method="POST" fetcher.submit(e.currentTarget)
onSubmit={(e) => { close()
submit(e.currentTarget) }}>
}}
>
<input type="hidden" name="_method" value="register" /> <input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" /> <input type="hidden" name="id" value="_" />
<TextField <TextField
label='Machine Key' label='Machine Key'
placeholder='nodekey:ff.....' placeholder='mkey:ff.....'
name="mkey" name="mkey"
state={[mkey, setMkey]} state={[mkey, setMkey]}
className='my-2 font-mono' className='my-2 font-mono'
@@ -79,12 +93,17 @@ export default function New(data: NewProps) {
</Dialog.Action> </Dialog.Action>
<Dialog.Action <Dialog.Action
variant="confirm" variant="confirm"
onPress={close} isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
> >
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Register Register
</Dialog.Action> </Dialog.Action>
</div> </div>
</Form> </fetcher.Form>
</> </>
)} )}
</Dialog.Panel> </Dialog.Panel>
@@ -104,9 +123,11 @@ export default function New(data: NewProps) {
<ServerIcon className='w-4 h-4 mr-2'/> <ServerIcon className='w-4 h-4 mr-2'/>
Register Machine Key Register Machine Key
</Menu.ItemButton> </Menu.ItemButton>
<Menu.ItemButton control={pkeyState} isDisabled> <Menu.ItemButton>
<KeyIcon className='w-4 h-4 mr-2'/> <Link to="/settings/auth-keys">
Generate Pre-auth Key <KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Link>
</Menu.ItemButton> </Menu.ItemButton>
</Menu.Items> </Menu.Items>
</Menu> </Menu>
+2 -1
View File
@@ -4,7 +4,7 @@ import { Link } from '@remix-run/react'
import Menu from '~/components/Menu' import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster'
import { type Machine, type Route, User } from '~/types' import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import MenuOptions from './menu' import MenuOptions from './menu'
@@ -19,6 +19,7 @@ interface Props {
export default function MachineRow({ machine, routes, magic, users }: Props) { export default function MachineRow({ machine, routes, magic, users }: Props) {
const expired = machine.expiry === '0001-01-01 00:00:00' const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z' || machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false ? false
: new Date(machine.expiry).getTime() < Date.now() : new Date(machine.expiry).getTime() < Date.now()
@@ -29,6 +29,7 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
const expired = machine.expiry === '0001-01-01 00:00:00' const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z' || machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false ? false
: new Date(machine.expiry).getTime() < Date.now() : new Date(machine.expiry).getTime() < Date.now()
-13
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>
)
}
@@ -0,0 +1,48 @@
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'>Settings</h1>
<p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction.
As I'm able to add more features, I'll be adding them here.
If you require any features, feel free to open an issue on
the GitHub repository.
</p>
</div>
<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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+1 -20
View File
@@ -8,10 +8,9 @@ import Dialog from '~/components/Dialog'
interface Props { interface Props {
username: string username: string
magic?: string
} }
export default function Remove({ username, magic }: Props) { export default function Remove({ username }: Props) {
const submit = useSubmit() const submit = useSubmit()
const dialogState = useState(false) const dialogState = useState(false)
@@ -41,24 +40,6 @@ export default function Remove({ username, magic }: Props) {
? ?
{' '} {' '}
A deleted user cannot be recovered. A deleted user cannot be recovered.
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will become orphaned and inaccessible.
</p>
)
: undefined}
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
-27
View File
@@ -3,7 +3,6 @@ import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react' import { useState } from 'react'
import Button from '~/components/Button' import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField' import TextField from '~/components/TextField'
@@ -40,32 +39,6 @@ export default function Rename({ username, magic }: Props) {
Enter a new username for Enter a new username for
{' '} {' '}
{username} {username}
?
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will now become accessible via
{' '}
<Code>
[machine].
.
{magic}
</Code>
.
</p>
)
: undefined}
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
+1 -1
View File
@@ -335,7 +335,7 @@ function UserCard({ user, magic }: CardProps) {
<Rename username={user.name} magic={magic} /> <Rename username={user.name} magic={magic} />
{user.machines.length === 0 {user.machines.length === 0
? ( ? (
<Remove username={user.name} magic={magic} /> <Remove username={user.name} />
) )
: undefined} : undefined}
</div> </div>
+11
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[]
}
+1
View File
@@ -2,3 +2,4 @@ export * from './Key'
export * from './Machine' export * from './Machine'
export * from './Route' export * from './Route'
export * from './User' export * from './User'
export * from './PreAuthKey'
+4 -4
View File
@@ -51,8 +51,8 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
const challenge = await calculatePKCECodeChallenge(verifier) const challenge = await calculatePKCECodeChallenge(verifier)
const callback = new URL('/admin/oidc/callback', req.url) const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:' callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.hostname = req.headers.get('Host') ?? '' callback.host = req.headers.get('Host') ?? ''
const authUrl = new URL(processed.authorization_endpoint) const authUrl = new URL(processed.authorization_endpoint)
authUrl.searchParams.set('client_id', oidcClient.client_id) authUrl.searchParams.set('client_id', oidcClient.client_id)
@@ -119,8 +119,8 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
} }
const callback = new URL('/admin/oidc/callback', req.url) const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:' callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.hostname = req.headers.get('Host') ?? '' callback.host = req.headers.get('Host') ?? ''
const tokenResponse = await authorizationCodeGrantRequest( const tokenResponse = await authorizationCodeGrantRequest(
processed, processed,
+1 -1
View File
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
services: services:
headplane: headplane:
container_name: headplane container_name: headplane
image: ghcr.io/tale/headplane:0.3.0 image: ghcr.io/tale/headplane:0.3.2
restart: unless-stopped restart: unless-stopped
ports: ports:
- '3000:3000' - '3000:3000'
+1 -1
View File
@@ -50,7 +50,7 @@ services:
TZ: 'America/New_York' TZ: 'America/New_York'
headplane: headplane:
container_name: headplane container_name: headplane
image: ghcr.io/tale/headplane:0.3.0 image: ghcr.io/tale/headplane:0.3.2
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- './data:/var/lib/headscale' - './data:/var/lib/headscale'
+1 -1
View File
@@ -88,7 +88,7 @@ spec:
serviceAccountName: default serviceAccountName: default
containers: containers:
- name: headplane - name: headplane
image: ghcr.io/tale/headplane:0.3.0 image: ghcr.io/tale/headplane:0.3.2
env: env:
- name: COOKIE_SECRET - name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz' value: 'abcdefghijklmnopqrstuvwxyz'
+11 -11
View File
@@ -689,8 +689,8 @@ packages:
'@humanwhocodes/object-schema@2.0.2': '@humanwhocodes/object-schema@2.0.2':
resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==}
'@internationalized/date@3.5.4': '@internationalized/date@3.5.6':
resolution: {integrity: sha512-qoVJVro+O0rBaw+8HPjUB1iH8Ihf8oziEnqMnvhJUSuVIrHOuZ6eNLHNvzXJKUvAtaDiqMnRlg8Z2mgh09BlUw==} resolution: {integrity: sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==}
'@internationalized/message@3.1.4': '@internationalized/message@3.1.4':
resolution: {integrity: sha512-Dygi9hH1s7V9nha07pggCkvmRfDd3q2lWnMGvrJyrOwYMe1yj4D2T9BoH9I6MGR7xz0biQrtLPsqUkqXzIrBOw==} resolution: {integrity: sha512-Dygi9hH1s7V9nha07pggCkvmRfDd3q2lWnMGvrJyrOwYMe1yj4D2T9BoH9I6MGR7xz0biQrtLPsqUkqXzIrBOw==}
@@ -5157,7 +5157,7 @@ snapshots:
'@humanwhocodes/object-schema@2.0.2': {} '@humanwhocodes/object-schema@2.0.2': {}
'@internationalized/date@3.5.4': '@internationalized/date@3.5.6':
dependencies: dependencies:
'@swc/helpers': 0.5.11 '@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)': '@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: 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/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/interactions': 3.21.3(react@19.0.0-rc-f38c22b244-20240704)
'@react-aria/live-announcer': 3.3.4 '@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)': '@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: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/number': 3.5.3 '@internationalized/number': 3.5.3
'@internationalized/string': 3.2.3 '@internationalized/string': 3.2.3
'@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704) '@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)': '@react-aria/i18n@3.11.1(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/message': 3.1.4 '@internationalized/message': 3.1.4
'@internationalized/number': 3.5.3 '@internationalized/number': 3.5.3
'@internationalized/string': 3.2.3 '@internationalized/string': 3.2.3
@@ -5879,7 +5879,7 @@ snapshots:
'@react-stately/calendar@3.5.1(react@19.0.0-rc-f38c22b244-20240704)': '@react-stately/calendar@3.5.1(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: 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-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/calendar': 3.4.6(react@19.0.0-rc-f38c22b244-20240704)
'@react-types/shared': 3.23.1(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)': '@react-stately/datepicker@3.9.4(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/string': 3.2.3 '@internationalized/string': 3.2.3
'@react-stately/form': 3.0.3(react@19.0.0-rc-f38c22b244-20240704) '@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) '@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)': '@react-types/calendar@3.4.6(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: 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-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704)
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)': '@react-types/datepicker@3.7.4(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: 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/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/overlays': 3.8.7(react@19.0.0-rc-f38c22b244-20240704)
'@react-types/shared': 3.23.1(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): 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: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/string': 3.2.3 '@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/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) '@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704)