Compare commits

..

15 Commits

Author SHA1 Message Date
Aarnav Tale bcf00beb75 fix: add publish archs 2024-04-02 15:08:47 -04:00
Aarnav Tale 6dae5d647a chore: various fixes 2024-04-01 19:41:39 -04:00
Aarnav Tale d787b8517e fix: protect the restart endpoint with auth 2024-04-01 19:33:22 -04:00
Aarnav Tale bdb00b6cd7 feat: make the modal and dropdown more workable 2024-03-31 19:06:06 -04:00
Aarnav Tale f1347803a4 fix: logout is a button 2024-03-31 18:14:28 -04:00
Aarnav Tale 6fa27e5d28 fix: read oidc configuration from env then config 2024-03-30 19:11:13 -04:00
Aarnav Tale 78140927ad fix: use either HEADSCALE_URL or config.server_url 2024-03-30 19:05:31 -04:00
Aarnav Tale a47fb61549 feat: implement node removal 2024-03-30 18:58:16 -04:00
Aarnav Tale 1d9f4553eb feat: support children in confirmation modal 2024-03-30 18:42:49 -04:00
Aarnav Tale b146e4c3a8 feat: add confirmation modal 2024-03-30 18:39:46 -04:00
Aarnav Tale 381c3d6df4 feat: create shared dropdown component 2024-03-30 15:22:34 -04:00
Aarnav Tale 37f84cfba5 fix: use the request url protocol for oidc 2024-03-30 05:26:22 -04:00
Aarnav Tale 52c8e2c3bc fix: remix relies on build dir 2024-03-30 05:06:17 -04:00
Aarnav Tale 0a8d3e26f4 chore: we now have images! 2024-03-30 04:51:37 -04:00
Aarnav Tale 9342e81733 chore: wording on actions should be ghcr.io 2024-03-30 04:51:34 -04:00
16 changed files with 601 additions and 243 deletions
+3 -2
View File
@@ -6,7 +6,7 @@ on:
jobs:
publish:
name: Push Docker image to Docker Hub
name: Build and Publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
@@ -18,7 +18,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -39,3 +39,4 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
+2 -2
View File
@@ -11,11 +11,11 @@ RUN pnpm prune --prod
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/build /app
COPY --from=build /app/build /app/build
COPY --from=build /app/node_modules /app/node_modules
RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "server/index.js" ]
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
-2
View File
@@ -14,8 +14,6 @@ It's still very early in it's development, however these are some of the feature
- [x] Editable headscale configuration
## Deployment
> The docker image is not available yet. For now you can build it locally with `docker build -t ghcr.io/tale/headplane:latest .`
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
+86
View File
@@ -0,0 +1,86 @@
import { Menu, type MenuButtonProps, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { Fragment, type HTMLProps, type ReactNode } from 'react'
type Properties = {
readonly children: ReactNode;
readonly button: ReactNode;
// eslint-disable-next-line unicorn/no-keyword-prefix
readonly className?: string;
readonly width?: string;
}
function Dropdown(properties: Properties) {
return (
<div className={clsx('relative', properties.className)}>
<Menu>
<Button className='flex flex-col items-center'>
{properties.button}
</Button>
<Transition
as={Fragment}
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
>
<Menu.Items className={clsx(
'absolute right-0 mt-2 rounded-md',
'text-gray-700 dark:text-gray-300',
'bg-white dark:bg-zinc-800',
'overflow-hidden z-50',
'border border-gray-200 dark:border-zinc-700',
'divide-y divide-gray-200 dark:divide-zinc-700',
properties.width ?? 'w-36'
)}
>
{properties.children}
</Menu.Items>
</Transition>
</Menu>
</div>
)
}
function Button(properties: MenuButtonProps<'button'>) {
return (
<Menu.Button
{...properties}
className={clsx(
properties.className
)}
>
{properties.children}
</Menu.Button>
)
}
type ItemProperties = HTMLProps<HTMLDivElement> & {
variant?: 'static' | 'normal';
}
function Item(properties: ItemProperties) {
return (
<Menu.Item>
{({ active }) => (
<div
{...properties}
className={clsx(
'px-4 py-2 w-full',
'focus:outline-none focus:ring',
'focus:ring-gray-300 dark:focus:ring-zinc-700',
properties.className,
properties.variant !== 'static' && active
? 'bg-gray-100 dark:bg-zinc-500' : ''
)}
>
{properties.children}
</div>
)}
</Menu.Item>
)
}
export default Object.assign(Dropdown, { Item })
+158
View File
@@ -0,0 +1,158 @@
import { Dialog, Transition } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { Fragment, type ReactNode, type SetStateAction, useState } from 'react'
import Button from './Button'
type HookParameters = {
title: string;
description?: string;
buttonText?: string;
variant?: 'danger' | 'confirm';
children?: ReactNode;
// Optional because the button submits
onConfirm?: () => void | Promise<void>;
onOpen?: () => void | Promise<void>;
onClose?: () => void | Promise<void>;
}
type Overrides = Omit<HookParameters, 'onOpen' | 'onClose'>
type Properties = {
readonly isOpen: boolean;
readonly setIsOpen: (value: SetStateAction<boolean>) => void;
readonly parameters?: HookParameters;
}
export default function useModal(properties?: HookParameters) {
const [isOpen, setIsOpen] = useState(false)
const [liveProperties, setLiveProperties] = useState(properties)
return {
Modal: (
<Modal
isOpen={isOpen}
setIsOpen={setIsOpen}
parameters={liveProperties}
/>
),
open: (overrides?: Overrides) => {
if (!overrides && !properties) {
throw new Error('No properties provided')
}
setIsOpen(true)
if (properties?.onOpen) {
void properties.onOpen()
}
if (overrides) {
setLiveProperties(overrides)
}
},
close: () => {
setIsOpen(false)
if (properties?.onClose) {
void properties.onClose()
}
}
}
}
function Modal({ parameters, isOpen, setIsOpen }: Properties) {
if (!parameters) {
return
}
return (
<Transition
show={isOpen}
as={Fragment}
>
<Dialog
as='div'
className='relative z-50'
onClose={() => {
setIsOpen(false)
}}
>
<Transition.Child
enter='ease-out duration-100'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-75'
leaveFrom='opacity-100'
leaveTo='opacity-0'
as={Fragment}
>
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
</Transition.Child>
<div className='fixed inset-0 flex w-screen items-center justify-center'>
<Transition.Child
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
as={Fragment}
>
<Dialog.Panel className={clsx(
'rounded-lg p-4 w-full max-w-md',
'bg-white dark:bg-black relative',
'border border-gray-200 dark:border-zinc-800'
)}
>
<XMarkIcon
className={clsx(
'absolute top-3 right-3 rounded-lg p-1.5',
'w-8 h-8 text-gray-500 dark:text-gray-400',
'hover:bg-gray-100 dark:hover:bg-zinc-800'
)}
onClick={() => {
setIsOpen(false)
}}
/>
<Dialog.Title className='text-xl font-bold'>
{parameters.title}
</Dialog.Title>
{parameters.description ? (
<Dialog.Description className='text-gray-500 dark:text-gray-400 mt-1'>
{parameters.description}
</Dialog.Description>
) : undefined}
{parameters.children ? (
<div className='w-full mt-4'>
{parameters.children}
</div>
) : undefined}
<Button
variant='emphasized'
type='submit'
className={clsx(
'w-full mt-12',
parameters.variant === 'danger'
? 'bg-red-800 dark:bg-red-500 focus:ring-red-500 dark:focus:ring-red-500'
: ''
)}
onClick={async () => {
if (parameters.onConfirm) {
await parameters.onConfirm()
}
setIsOpen(false)
}}
>
{parameters.buttonText ?? 'Confirm'}
</Button>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
)
}
+3 -3
View File
@@ -22,11 +22,11 @@ export const links: LinksFunction = () => [
]
export async function loader() {
await getContext()
const context = await getContext()
registerConfigWatcher()
if (!process.env.HEADSCALE_URL) {
throw new Error('The HEADSCALE_URL environment variable is required')
if (context.headscaleUrl.length === 0) {
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
}
if (!process.env.COOKIE_SECRET) {
+18 -45
View File
@@ -1,9 +1,7 @@
import { Dialog } from '@headlessui/react'
import { useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react'
import Button from '~/components/Button'
import useModal from '~/components/Modal'
import Spinner from '~/components/Spinner'
type Properties = {
@@ -13,8 +11,22 @@ type Properties = {
}
export default function Modal({ isEnabled, disabled }: Properties) {
const [isOpen, setIsOpen] = useState(false)
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
variant: isEnabled ? 'danger' : 'confirm',
buttonText: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
description: 'Devices will no longer be accessible via your tailnet domain. The search domain will also be disabled.',
onConfirm: () => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
}
})
return (
<>
@@ -23,7 +35,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
className='w-fit text-sm'
disabled={disabled}
onClick={() => {
setIsOpen(true)
open()
}}
>
{fetcher.state === 'idle' ? undefined : (
@@ -31,46 +43,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
)}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Button>
<Dialog
className='relative z-50'
open={isOpen} onClose={() => {
setIsOpen(false)
}}
>
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
<div className='fixed inset-0 flex w-screen items-center justify-center'>
<Dialog.Panel className='bg-white rounded-lg p-4 w-full max-w-md'>
<Dialog.Title className='text-lg font-bold'>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Description className='text-gray-500 dark:text-gray-400'>
Devices will no longer be accessible via your tailnet domain.
The search domain will also be disabled.
</Dialog.Description>
<Button
variant='emphasized'
type='submit'
className={clsx(
'w-full mt-12',
isEnabled ? 'bg-red-800 dark:bg-red-500' : ''
)}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
setIsOpen(false)
}}
>
{isEnabled ? 'Disable' : 'Enable'}
</Button>
</Dialog.Panel>
</div>
</Dialog>
{Modal}
</>
)
}
+26 -46
View File
@@ -7,6 +7,7 @@ import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Input from '~/components/Input'
import useModal from '~/components/Modal'
import Spinner from '~/components/Spinner'
type Properties = {
@@ -16,9 +17,31 @@ type Properties = {
}
export default function Modal({ name, disabled }: Properties) {
const [isOpen, setIsOpen] = useState(false)
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: 'Rename Tailnet',
description: 'Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.',
buttonText: 'Rename',
children: (
<Input
type='text'
className='font-mono mt-4'
value={newName}
onChange={event => {
setNewName(event.target.value)
}}
/>
),
onConfirm: () => {
fetcher.submit({
'dns_config.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
}
})
return (
<div className='flex flex-col w-2/3'>
@@ -47,7 +70,7 @@ export default function Modal({ name, disabled }: Properties) {
className='text-sm w-fit'
disabled={disabled}
onClick={() => {
setIsOpen(true)
open()
}}
>
{fetcher.state === 'idle' ? undefined : (
@@ -55,50 +78,7 @@ export default function Modal({ name, disabled }: Properties) {
)}
Rename Tailnet...
</Button>
<Dialog
className='relative z-50'
open={isOpen} onClose={() => {
setIsOpen(false)
}}
>
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
<div className='fixed inset-0 flex w-screen items-center justify-center'>
<Dialog.Panel className='bg-white rounded-lg p-4 w-full max-w-md dark:bg-zinc-800'>
<Dialog.Title className='text-lg font-bold'>
Rename {name}
</Dialog.Title>
<Dialog.Description className='text-gray-500 dark:text-gray-400'>
Keep in mind that changing this can lead to all sorts
of unexpected behavior and may break existing devices
in your tailnet.
</Dialog.Description>
<Input
type='text'
className='font-mono mt-4'
value={newName}
onChange={event => {
setNewName(event.target.value)
}}
/>
<button
type='submit'
className='rounded-lg py-2 bg-gray-800 text-white w-full mt-2'
onClick={() => {
fetcher.submit({
'dns_config.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
setIsOpen(false)
}}
>
Rename
</button>
</Dialog.Panel>
</div>
</Dialog>
{Modal}
</div>
)
}
+11 -1
View File
@@ -12,6 +12,7 @@ import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Domains from './domains'
@@ -45,9 +46,18 @@ export async function loader() {
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
})
}
const context = await getContext()
if (!context.hasConfigWrite) {
return json({ success: false })
return json({ success: false }, {
status: 403
})
}
const data = await request.json() as Record<string, unknown>
+185 -75
View File
@@ -1,98 +1,208 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react'
import { toast } from 'react-hot-toast/headless'
import Dropdown from '~/components/Dropdown'
import useModal from '~/components/Modal'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { pull } from '~/utils/headscale'
import { del, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
return data.nodes
}
export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as { id?: string }
if (!data.id) {
return json({ message: 'No ID provided' }, {
status: 400
})
}
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401
})
}
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
const { Modal, open } = useModal()
return (
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-sm font-bold'>
<th className='pl-4'>Name</th>
<th>IP Addresses</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody className={clsx(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700'
)}
>
{data.map(machine => {
const tags = [...machine.forcedTags, ...machine.validTags]
return (
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800'>
<td className='py-2 pl-4'>
<Link to={`/machines/${machine.id}`}>
<h1>{machine.givenName}</h1>
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
{machine.name}
</span>
<div className='flex gap-1 mt-1'>
{tags.map(tag => (
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
{machine.ipAddresses.map((ip, index) => (
<button
key={ip}
type='button'
className='flex items-center gap-x-1 w-full'
onClick={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
{ip}
<>
{Modal}
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-sm font-bold'>
<th className='pl-4'>Name</th>
<th>IP Addresses</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody className={clsx(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700'
)}
>
{data.map(machine => {
const tags = [...machine.forcedTags, ...machine.validTags]
return (
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'>
<td className='py-2 pl-4'>
<Link to={`/machines/${machine.id}`}>
<h1>{machine.givenName}</h1>
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
{machine.name}
</span>
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
</button>
))}
</td>
<td className='py-2'>
<span
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
>
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
<p>
{machine.online
? 'Connected'
: new Date(
machine.lastSeen
).toLocaleString()}
</p>
</span>
</td>
</tr>
)
})}
</tbody>
</table>
<div className='flex gap-1 mt-1'>
{tags.map(tag => (
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
{machine.ipAddresses.map((ip, index) => (
<button
key={ip}
type='button'
className='flex items-center gap-x-1 w-full'
onClick={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
{ip}
</span>
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
</button>
))}
</td>
<td className='py-2'>
<span
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
>
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
<p>
{machine.online
? 'Connected'
: new Date(
machine.lastSeen
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-4'>
<div className={clsx(
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<Dropdown
className='left-1/4'
width='w-48'
button={(
<EllipsisHorizontalIcon className='w-5 h-5'/>
)}
>
<Dropdown.Item variant='static'>
<button
disabled
type='button'
className='text-left w-full opacity-50'
onClick={() => {
open()
}}
>
Edit machine name
</button>
</Dropdown.Item>
<Dropdown.Item variant='static'>
<button
disabled
type='button'
className='text-left w-full opacity-50'
onClick={() => {
open()
}}
>
Edit route settings
</button>
</Dropdown.Item>
<Dropdown.Item variant='static'>
<button
disabled
type='button'
className='text-left w-full opacity-50'
onClick={() => {
open()
}}
>
Edit ACL tags
</button>
</Dropdown.Item>
<Dropdown.Item>
<button
type='button'
className='text-left text-red-700 w-full'
onClick={() => {
open({
title: 'Remove Machine',
description: [
'This action is irreversible and will disconnect the machine from the Headscale server.',
'All data associated with this machine including ACLs and tags will be lost.'
].join('\n'),
variant: 'danger',
buttonText: 'Remove',
onConfirm: () => {
fetcher.submit(
{
id: machine.id
},
{
method: 'DELETE',
encType: 'application/json'
}
)
}
})
}}
>
Remove
</button>
</Dropdown.Item>
</Dropdown>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)
}
+16 -53
View File
@@ -1,10 +1,8 @@
import { Menu, Transition } from '@headlessui/react'
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Outlet, useLoaderData, useRouteError } from '@remix-run/react'
import clsx from 'clsx'
import { Fragment } from 'react/jsx-runtime'
import Dropdown from '~/components/Dropdown'
import { ErrorPopup } from '~/components/Error'
import TabLink from '~/components/TabLink'
import { getContext } from '~/utils/config'
@@ -64,56 +62,21 @@ export default function Layout() {
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<div className='relative'>
<Menu>
<Menu.Button>
<UserCircleIcon className='w-8 h-8'/>
</Menu.Button>
<Transition
as={Fragment}
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
>
<Menu.Items className={clsx(
'absolute right-0 w-36 mt-2 rounded-md',
'text-gray-700 dark:text-gray-300',
'bg-white dark:bg-zinc-800 text-right',
'overflow-hidden',
'border border-gray-200 dark:border-zinc-500',
'divide-y divide-gray-200 dark:divide-zinc-500'
)}
>
<Menu.Item>
{() => (
<div className='px-4 py-2'>
<p>{data.user?.name}</p>
<p>{data.user?.email}</p>
</div>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<Form method='POST' action='/logout'>
<button
type='submit'
className={clsx(
'px-4 py-2 w-full text-right',
active ? 'bg-gray-200 dark:bg-zinc-500' : ''
)}
>
Logout
</button>
</Form>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</div>
<Dropdown
button={<UserCircleIcon className='w-8 h-8'/>}
>
<Dropdown.Item variant='static'>
<p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p>
</Dropdown.Item>
<Dropdown.Item className='text-red-700 cursor-pointer'>
<Form method='POST' action='/logout'>
<button type='submit' className='w-full'>
Logout
</button>
</Form>
</Dropdown.Item>
</Dropdown>
</div>
</div>
<div className='flex items-center gap-x-4'>
+11 -5
View File
@@ -7,6 +7,7 @@ import Card from '~/components/Card'
import Code from '~/components/Code'
import Input from '~/components/Input'
import { type Key } from '~/types'
import { getContext } from '~/utils/config'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
@@ -22,9 +23,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
})
}
const issuer = process.env.OIDC_ISSUER
const id = process.env.OIDC_CLIENT_ID
const secret = process.env.OIDC_CLIENT_SECRET
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
const secret = context.oidcConfig?.secret
const normal = process.env.DISABLE_API_KEY_LOGIN
if (issuer && (!id || !secret)) {
@@ -51,9 +53,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = String(formData.get('oidc-start'))
if (oidcStart) {
const issuer = process.env.OIDC_ISSUER
const id = process.env.OIDC_CLIENT_ID
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
// We know it exists here because this action only happens on OIDC
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(issuer!, id!, request)
}
+5 -5
View File
@@ -1,15 +1,15 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { getContext } from '~/utils/config'
import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const issuer = process.env.OIDC_ISSUER
const id = process.env.OIDC_CLIENT_ID
const secret = process.env.OIDC_CLIENT_SECRET
const context = await getContext()
const oidc = context.oidcConfig
if (!issuer || !id || !secret) {
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
return finishOidc(issuer, id, secret, request)
return finishOidc(oidc.issuer, oidc.client, oidc.secret, request)
}
+49 -1
View File
@@ -161,6 +161,12 @@ type Context = {
hasConfigWrite: boolean;
hasAcl: boolean;
hasAclWrite: boolean;
headscaleUrl: string;
oidcConfig?: {
issuer: string;
client: string;
secret: string;
};
}
export let context: Context
@@ -172,13 +178,55 @@ export async function getContext() {
hasConfig: await hasConfig(),
hasConfigWrite: await hasConfigW(),
hasAcl: await hasAcl(),
hasAclWrite: await hasAclW()
hasAclWrite: await hasAclW(),
headscaleUrl: await getHeadscaleUrl(),
oidcConfig: await getOidcConfig()
}
}
return context
}
async function getOidcConfig() {
// Check for the OIDC environment variables first
let issuer = process.env.OIDC_ISSUER
let client = process.env.OIDC_CLIENT
let secret = process.env.OIDC_SECRET
if (!issuer || !client || !secret) {
const config = await getConfig()
issuer = config.oidc?.issuer
client = config.oidc?.client_id
secret = config.oidc?.client_secret
}
// If atleast one is defined but not all 3, throw an error
if ((issuer || client || secret) && !(issuer && client && secret)) {
throw new Error('OIDC configuration is incomplete')
}
if (!issuer || !client || !secret) {
return
}
return { issuer, client, secret }
}
async function getHeadscaleUrl() {
if (process.env.HEADSCALE_URL) {
return process.env.HEADSCALE_URL
}
try {
const config = await getConfig()
if (config.server_url) {
return config.server_url
}
} catch {}
return ''
}
async function checkSock() {
try {
await access('/var/run/docker.sock', constants.R_OK)
+22 -3
View File
@@ -1,3 +1,5 @@
import { getContext } from './config'
export class HeadscaleError extends Error {
status: number
@@ -15,9 +17,9 @@ export class FatalError extends Error {
}
}
/* eslint-disable @typescript-eslint/no-non-null-assertion */
export async function pull<T>(url: string, key: string) {
const prefix = process.env.HEADSCALE_URL!
const context = await getContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`
@@ -32,7 +34,8 @@ export async function pull<T>(url: string, key: string) {
}
export async function post<T>(url: string, key: string, body?: unknown) {
const prefix = process.env.HEADSCALE_URL!
const context = await getContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
@@ -48,3 +51,19 @@ export async function post<T>(url: string, key: string, body?: unknown) {
return (response.json() as Promise<T>)
}
export async function del<T>(url: string, key: string) {
const context = await getContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}
+6
View File
@@ -43,7 +43,10 @@ export async function startOidc(issuer: string, client: string, request: Request
const nonce = generateRandomNonce()
const verifier = generateRandomCodeVerifier()
const challenge = await calculatePKCECodeChallenge(verifier)
const callback = new URL('/admin/oidc/callback', request.url)
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = request.headers.get('Host') ?? ''
const authUrl = new URL(processed.authorization_endpoint)
authUrl.searchParams.set('client_id', oidcClient.client_id)
@@ -106,6 +109,9 @@ export async function finishOidc(issuer: string, client: string, secret: string,
}
const callback = new URL('/admin/oidc/callback', request.url)
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = request.headers.get('Host') ?? ''
const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier)
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
if (challenges) {