mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 447a31f91e | |||
| 9e21823163 | |||
| aa7e2a3128 | |||
| ecef45c98a | |||
| d165264876 | |||
| dd479d4117 | |||
| 29d91785fb | |||
| 98d1cb1333 |
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Generated
+11
-11
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user