feat: use strictly typed configs and context

This commit is contained in:
Aarnav Tale 2024-05-20 14:05:09 -04:00
parent 0a12cdb3d6
commit 3e51e4861d
No known key found for this signature in database
17 changed files with 742 additions and 676 deletions

View File

@ -2,30 +2,30 @@ import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIco
import { Form } from '@remix-run/react'
import { cn } from '~/utils/cn'
import { type Context } from '~/utils/config'
import { HeadplaneContext } from '~/utils/config/headplane'
import { type SessionData } from '~/utils/sessions'
import Menu from './Menu'
import TabLink from './TabLink'
type Properties = {
readonly data?: Context & { user?: SessionData['user'] };
interface Properties {
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
}
type LinkProperties = {
readonly href: string;
readonly text: string;
readonly isMenu?: boolean;
interface LinkProperties {
readonly href: string
readonly text: string
readonly isMenu?: boolean
}
function Link({ href, text, isMenu }: LinkProperties) {
return (
<a
href={href}
target='_blank'
rel='noreferrer'
target="_blank"
rel="noreferrer"
className={cn(
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block'
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
)}
>
{text}
@ -35,74 +35,82 @@ function Link({ href, text, isMenu }: LinkProperties) {
export default function Header({ data }: Properties) {
return (
<header className='bg-main-700 dark:bg-main-800 text-ui-50'>
<div className='container flex items-center justify-between py-4'>
<div className='flex items-center gap-x-2'>
<PaperAirplaneIcon className='w-6 h-6'/>
<h1 className='text-2xl'>Headplane</h1>
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PaperAirplaneIcon className="w-6 h-6" />
<h1 className="text-2xl">Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<Link href='https://tailscale.com/download' text='Download'/>
<Link href='https://github.com/tale/headplane' text='GitHub'/>
<Link href='https://github.com/juanfont/headscale' text='Headscale'/>
{data?.user ? (
<Menu>
<Menu.Button className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700'
)}
>
<PersonIcon className='h-5 w-5 mt-0.5'/>
</Menu.Button>
<Menu.Items>
<Menu.Item className='text-right'>
<p className='font-bold'>{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://tailscale.com/download'
text='Download'
/>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://github.com/tale/headplane'
text='GitHub'
/>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://github.com/juanfont/headscale'
text='Headscale'
/>
</Menu.Item>
<Menu.Item className='text-red-500 dark:text-red-400'>
<Form method='POST' action='/logout'>
<button type='submit' className='w-full text-right'>
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
) : undefined}
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data?.user
? (
<Menu>
<Menu.Button className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700',
)}
>
<PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout">
<button type="submit" className="w-full text-right">
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
)
: undefined}
</div>
</div>
<nav className='container flex items-center gap-x-4 overflow-x-auto'>
<TabLink to='/machines' name='Machines' icon={<ServerIcon className='w-4 h-4'/>}/>
<TabLink to='/users' name='Users' icon={<PeopleIcon className='w-4 h-4'/>}/>
{data?.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockIcon className='w-4 h-4'/>}/> : undefined}
{data?.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeIcon className='w-4 h-4'/>}/>
<TabLink to='/settings' name='Settings' icon={<GearIcon className='w-4 h-4'/>}/>
</>
) : undefined}
<nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
{data?.acl.read
? (
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
)
: undefined}
{data?.config.read
? (
<>
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
</>
)
: undefined}
</nav>
</header>
)

View File

@ -4,62 +4,45 @@ import {
Meta,
Outlet,
Scripts,
ScrollRestoration
ScrollRestoration,
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config'
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }
{ name: 'description', content: 'A frontend for the headscale coordination server' },
]
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }
{ rel: 'stylesheet', href: stylesheet },
]
export async function loader() {
const context = await getContext()
registerConfigWatcher()
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) {
throw new Error('The COOKIE_SECRET environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
export function Layout({ children }: { readonly children: React.ReactNode }) {
return (
<html lang='en'>
<html lang="en">
<head>
<meta charSet='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1'/>
<Meta/>
<Links/>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className='overscroll-none dark:bg-ui-950 dark:text-ui-50'>
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
{children}
<Toaster/>
<ScrollRestoration/>
<Scripts/>
<Toaster />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export function ErrorBoundary() {
return <ErrorPopup/>
return <ErrorPopup />
}
export default function App() {
return <Outlet/>
return <Outlet />
}

View File

@ -8,7 +8,7 @@ import { ClientOnly } from 'remix-utils/client-only'
import Link from '~/components/Link'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { getAcl, getContext, patchAcl } from '~/utils/config'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
@ -16,16 +16,16 @@ import Editor from './editor'
import Fallback from './fallback'
export async function loader() {
const context = await getContext()
if (!context.hasAcl) {
const context = await loadContext()
if (!context.acl.read) {
throw new Error('No ACL configuration is available')
}
const { data, type } = await getAcl()
const { data, type } = await loadAcl()
return {
hasAclWrite: context.hasAclWrite,
hasAclWrite: context.acl.write,
currentAcl: data,
aclType: type
aclType: type,
}
}
@ -33,21 +33,21 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
status: 401,
})
}
const context = await getContext()
if (!context.hasAclWrite) {
const context = await loadContext()
if (!context.acl.write) {
return json({ success: false }, {
status: 403
status: 403,
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.hasDockerSock) {
if (context.docker) {
await sighupHeadscale()
}
@ -60,26 +60,28 @@ export default function Page() {
return (
<div>
{data.hasAclWrite ? undefined : (
<div className='mb-4'>
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
</div>
)}
{data.hasAclWrite
? undefined
: (
<div className="mb-4">
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
</div>
)}
<h1 className='text-2xl font-medium mb-4'>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className='mb-4 max-w-prose'>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to='https://tailscale.com/kb/1018/acls'
name='Tailscale ACL documentation'
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
@ -87,8 +89,8 @@ export default function Page() {
and the
{' '}
<Link
to='https://headscale.net/acls'
name='Headscale ACL documentation'
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
@ -99,70 +101,70 @@ export default function Page() {
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500'
'text-gray-400 dark:text-gray-500',
)}
>
<Tab
id='edit'
id="edit"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<PencilIcon className='w-5 h-5'/>
<PencilIcon className="w-5 h-5" />
<p>Edit file</p>
</Tab>
<Tab
id='diff'
id="diff"
className={({ isSelected }) => cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<EyeIcon className='w-5 h-5'/>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id='preview'
id="preview"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<BeakerIcon className='w-5 h-5'/>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id='edit'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
<TabPanel id="edit">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" />
)}
</ClientOnly>
</TabPanel>
<TabPanel id='diff'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
<TabPanel id="diff">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" />
)}
</ClientOnly>
</TabPanel>
<TabPanel id='preview'>
<TabPanel id="preview">
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center'
'p-16 flex flex-col items-center justify-center',
)}
>
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='w-1/2 text-center mt-4'>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
</p>

View File

@ -9,7 +9,8 @@ import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
@ -20,27 +21,26 @@ import RenameModal from './rename'
// We do not want to expose every config value
export async function loader() {
const context = await getContext()
if (!context.hasConfig) {
const context = await loadContext()
if (!context.config.read) {
throw new Error('No configuration is available')
}
const config = await getConfig()
const config = await loadConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns ?? false,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns ?? false,
nameservers: config.dns_config.nameservers ?? [],
splitDns: config.dns_config.restricted_nameservers ?? {},
searchDomains: config.dns_config.domains ?? [],
extraRecords: config.dns_config.extra_records ?? []
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records,
}
return {
...dns,
...context
...context,
}
}
@ -48,14 +48,14 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
status: 401,
})
}
const context = await getContext()
if (!context.hasConfigWrite) {
const context = await loadContext()
if (!context.config.write) {
return json({ success: false }, {
status: 403
status: 403,
})
}
@ -73,39 +73,41 @@ export default function Page() {
const [ns, setNs] = useState('')
return (
<div className='flex flex-col gap-16 max-w-screen-lg'>
{data.hasConfigWrite ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
<p className='text-gray-700 dark:text-gray-300'>
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write
? undefined
: (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
</p>
<div className='mt-4'>
<div className='flex items-center justify-between mb-2'>
<h2 className='text-md font-medium opacity-80'>
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
Global Nameservers
</h2>
<div className='flex gap-2 items-center'>
<span className='text-sm opacity-50'>
<div className="flex gap-2 items-center">
<span className="text-sm opacity-50">
Override local DNS
</span>
<Switch
label='Override local DNS'
label="Override local DNS"
defaultSelected={localOverride}
isDisabled={!data.hasConfigWrite}
isDisabled={!data.config.write}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride
'dns_config.override_local_dns': !localOverride,
}, {
method: 'PATCH',
encType: 'application/json'
encType: 'application/json',
})
setLocalOverride(!localOverride)
@ -117,22 +119,22 @@ export default function Page() {
{data.nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
!data.hasConfigWrite && 'opacity-50 cursor-not-allowed'
!data.config.write && 'opacity-50 cursor-not-allowed',
)}
isDisabled={!data.hasConfigWrite}
isDisabled={!data.config.write}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index),
}, {
method: 'PATCH',
encType: 'application/json'
encType: 'application/json',
})
}}
>
@ -140,45 +142,49 @@ export default function Page() {
</Button>
</TableList.Item>
))}
{data.hasConfigWrite ? (
<TableList.Item>
<Input
type='text'
className='font-mono text-sm bg-transparent w-full mr-2'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className={cn(
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300',
ns.length === 0 && 'opacity-50 cursor-not-allowed'
)}
isDisabled={ns.length === 0}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNs('')
{data.config.write
? (
<TableList.Item>
<Input
type="text"
className="font-mono text-sm bg-transparent w-full mr-2"
placeholder="Nameserver"
value={ns}
onChange={(event) => {
setNs(event.target.value)
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
) : undefined}
/>
{fetcher.state === 'idle'
? (
<Button
className={cn(
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300',
ns.length === 0 && 'opacity-50 cursor-not-allowed',
)}
isDisabled={ns.length === 0}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns],
}, {
method: 'PATCH',
encType: 'application/json',
})
setNs('')
}}
>
Add
</Button>
)
: (
<Spinner className="w-3 h-3 mr-0" />
)}
</TableList.Item>
)
: undefined}
</TableList>
{/* TODO: Split DNS and Custom A Records */}
</div>
@ -187,22 +193,23 @@ export default function Page() {
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
disabled={!data.config.write}
/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
<p className='text-gray-700 dark:text-gray-300 mb-4'>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].{data.baseDomain}
[device].[user].
{data.baseDomain}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div>
</div>
)

View File

@ -7,7 +7,8 @@ import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
@ -21,11 +22,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
])
const context = await getContext()
const context = await loadContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}

View File

@ -5,7 +5,7 @@ import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header'
import { cn } from '~/utils/cn'
import { getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
@ -24,8 +24,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
'Set-Cookie': await destroySession(session),
},
})
}
@ -33,10 +33,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
throw error
}
const context = await getContext()
const context = await loadContext()
return {
...context,
user: session.get('user')
user: session.get('user'),
}
}
@ -47,19 +47,19 @@ export default function Layout() {
return (
<>
<ProgressBar
aria-label='Loading...'
aria-label="Loading..."
>
<div
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100'
nav.state === 'loading' && 'animate-loading opacity-100',
)}
/>
</ProgressBar>
<Header data={data}/>
<main className='container mx-auto overscroll-contain mt-4 mb-24'>
<Outlet/>
<Header data={data} />
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
)
@ -68,8 +68,8 @@ export default function Layout() {
export function ErrorBoundary() {
return (
<>
<Header/>
<ErrorPopup type='embedded'/>
<Header />
<ErrorPopup type="embedded" />
</>
)
}

View File

@ -2,12 +2,12 @@ import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
import Card from '~/components/Card'
import Link from '~/components/Link'
import { type Context } from '~/utils/config'
import { HeadplaneContext } from '~/utils/config/headplane'
import Add from './add'
interface Props {
readonly oidc: NonNullable<Context['oidcConfig']>
readonly oidc: NonNullable<HeadplaneContext['oidc']>
readonly magic: string | undefined
}

View File

@ -12,7 +12,8 @@ import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type User } from '~/types'
import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
@ -35,18 +36,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
machines: machines.nodes.filter(machine => machine.user.id === user.id),
}))
const context = await getContext()
const context = await loadContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
}
return {
oidcConfig: context.oidcConfig,
oidc: context.oidc,
magic,
users,
}
@ -170,10 +171,10 @@ export default function Page() {
Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership.
</p>
{data.oidcConfig
{data.oidc
? (
<Oidc
oidc={data.oidcConfig}
oidc={data.oidc}
magic={data.magic}
/>
)

View File

@ -7,7 +7,7 @@ import Card from '~/components/Card'
import Code from '~/components/Code'
import TextField from '~/components/TextField'
import { type Key } from '~/types'
import { getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
@ -23,31 +23,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
})
}
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
const context = await loadContext()
if (issuer && (!id || !secret)) {
throw new Error('An invalid OIDC configuration was provided')
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
return startOidc(
context.oidc.issuer,
context.oidc.client,
request,
)
}
const data = {
oidc: issuer,
apiKey: normal !== 'true',
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
}
if (!data.oidc && !data.apiKey) {
throw new Error('No authentication method is enabled')
}
if (data.oidc && !data.apiKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(data.oidc, id!, request)
}
return data
}
export async function action({ request }: ActionFunctionArgs) {
@ -55,13 +45,17 @@ export async function action({ request }: ActionFunctionArgs) {
const oidcStart = formData.get('oidc-start')
if (oidcStart) {
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
const context = await loadContext()
const issuer = context.oidc?.issuer
const id = context.oidc?.client
if (!issuer || !id) {
throw new Error('An invalid OIDC configuration was provided')
}
// 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)
return startOidc(issuer, id, request)
}
const apiKey = String(formData.get('api-key'))

View File

@ -1,15 +1,18 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const context = await getContext()
const oidc = context.oidcConfig
if (!oidc) {
const context = await loadContext()
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
return finishOidc(oidc.issuer, oidc.client, oidc.secret, request)
return finishOidc(
context.oidc.issuer,
context.oidc.client,
context.oidc.secret,
request,
)
}

View File

@ -1,353 +0,0 @@
import { type Document, parse, parseDocument } from 'yaml'
import { type FSWatcher, watch } from 'node:fs'
import { access, constants, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
interface Config {
server_url: string
listen_addr: string
metrics_listen_addr: string
grpc_listen_addr: string
grpc_allow_insecure: boolean
private_key_path: string
noise: {
private_key_path: string
}
prefixes: {
v4: string
v6: string
}
derp: {
server: {
enabled: boolean
region_id: number
region_code: string
region_name: string
stun_listen_addr: string
}
urls: string[]
paths: string[]
auto_update_enabled: boolean
update_frequency: Duration
}
disable_check_updates: boolean
epheremal_node_inactivity_timeout: Duration
node_update_check_interval: Duration
// Database is probably dangerous
database: {
type: 'sqlite3' | 'sqlite' | 'postgres'
sqlite?: {
path: string
}
postgres?: {
host: string
port: number
name: string
user: string
pass: string
max_open_conns: number
max_idle_conns: number
conn_max_idle_time_secs: number
ssl: boolean
}
}
acme_url: string
acme_email: string
tls_letsencrypt_hostname: string
tls_letsencrypt_cache_dir: string
tls_letsencrypt_challenge_type: string
tls_letsencrypt_listen: string
tls_cert_path: string
tls_key_path: string
log: {
format: 'text' | 'json'
level: string
}
acl_policy_path: string
dns_config: {
override_local_dns: boolean
nameservers: string[]
restricted_nameservers: Record<string, string[]> // Split DNS
domains: string[]
extra_records: {
name: string
type: 'A'
value: string
}[]
magic_dns: boolean
base_domain: string
}
unix_socket: string
unix_socket_permission: string
oidc: {
only_start_if_oidc_is_available: boolean
issuer: string
client_id: string
client_secret: string
expiry: Duration
use_expiry_from_token: boolean
scope: string[]
extra_params: Record<string, string>
allowed_domains: string[]
allowed_groups: string[]
allowed_users: string[]
strip_email_domain: boolean
}
logtail: {
enabled: boolean
}
randomize_client_port: boolean
}
let config: Document
export async function getConfig(force = false) {
if (!config || force) {
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const data = await readFile(path, 'utf8')
config = parseDocument(data)
}
return config.toJSON() as Config
}
export async function getAcl() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return { data: '', type: 'json' }
}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml' }
} catch {
return { data, type: 'json' }
}
}
// This is so obscenely dangerous, please have a check around it
export async function patchConfig(partial: Record<string, unknown>) {
for (const [key, value] of Object.entries(partial)) {
config.setIn(key.split('.'), value)
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
await writeFile(path, config.toString(), 'utf8')
}
export async function patchAcl(data: string) {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
throw new Error('No ACL file defined')
}
await writeFile(path, data, 'utf8')
}
let watcher: FSWatcher
export function registerConfigWatcher() {
if (watcher) {
return
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
watcher = watch(path, async () => {
console.log('Config file changed, reloading')
await getConfig(true)
})
}
export interface Context {
hasDockerSock: boolean
hasConfig: boolean
hasConfigWrite: boolean
hasAcl: boolean
hasAclWrite: boolean
headscaleUrl: string
oidcConfig?: {
issuer: string
client: string
secret: string
}
}
export let context: Context
export async function getContext() {
if (!context) {
context = {
hasDockerSock: await checkSock(),
hasConfig: await hasConfig(),
hasConfigWrite: await hasConfigW(),
hasAcl: await hasAcl(),
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_ID
let secret = process.env.OIDC_CLIENT_SECRET
const rootKey = process.env.API_KEY
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
}
if (!rootKey) {
throw new Error('Cannot use OIDC without the root API_KEY variable set')
}
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)
return true
} catch {}
if (!process.env.HEADSCALE_CONTAINER) {
return false
}
return false
}
async function hasConfig() {
try {
await getConfig()
return true
} catch {}
return false
}
async function hasConfigW() {
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
try {
await access(path, constants.W_OK)
return true
} catch {}
return false
}
async function hasAcl() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return false
}
try {
path = resolve(path)
await access(path, constants.R_OK)
return true
} catch (error) {
console.log('Cannot acquire read access to ACL file', error)
}
return false
}
async function hasAclW() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return false
}
try {
path = resolve(path)
await access(path, constants.W_OK)
return true
} catch (error) {
console.log('Cannot acquire read access to ACL file', error)
}
return false
}

View File

@ -0,0 +1,240 @@
// Handle the configuration loading for headplane.
// Functionally only used for all sorts of sanity checks across headplane.
//
// Around the codebase, this is referred to as the context
import { access, constants, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { parse } from 'yaml'
import { HeadscaleConfig, loadConfig } from './headscale'
export interface HeadplaneContext {
headscaleUrl: string
cookieSecret: string
config: {
read: boolean
write: boolean
}
acl: {
read: boolean
write: boolean
}
docker?: {
sock: string
container: string
}
oidc?: {
issuer: string
client: string
secret: string
rootKey: string
disableKeyLogin: boolean
}
}
let context: HeadplaneContext | undefined
export async function loadContext(): Promise<HeadplaneContext> {
if (context) {
return context
}
let config: HeadscaleConfig | undefined
try {
config = await loadConfig()
} catch {}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
let headscaleUrl = process.env.HEADSCALE_URL
if (!headscaleUrl && !config) {
throw new Error('HEADSCALE_URL not set')
}
if (config) {
headscaleUrl = headscaleUrl ?? config.server_url
}
if (!headscaleUrl) {
throw new Error('Missing server_url in headscale config')
}
const cookieSecret = process.env.COOKIE_SECRET
if (!cookieSecret) {
throw new Error('COOKIE_SECRET not set')
}
context = {
headscaleUrl,
cookieSecret,
config: await checkConfig(path, config),
acl: await checkAcl(config),
docker: await checkDocker(),
oidc: await checkOidc(config),
}
console.log('Context loaded:', context)
return context
}
export async function loadAcl() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return { data: '', type: 'json' }
}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml' }
} catch {
return { data, type: 'json' }
}
}
export async function patchAcl(data: string) {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
throw new Error('No ACL file defined')
}
await writeFile(path, data, 'utf8')
}
async function checkConfig(path: string, config?: HeadscaleConfig) {
let write = false
try {
await access(path, constants.W_OK)
write = true
} catch {}
return {
read: config ? true : false,
write,
}
}
async function checkAcl(config?: HeadscaleConfig) {
let path = process.env.ACL_FILE
if (!path && config) {
path = config.acl_policy_path
}
let read = false
let write = false
if (path) {
try {
await access(path, constants.R_OK)
read = true
} catch {}
try {
await access(path, constants.W_OK)
write = true
} catch {}
}
return {
read,
write,
}
}
async function checkDocker() {
const path = process.env.DOCKER_SOCK ?? '/var/run/docker.sock'
try {
await access(path, constants.R_OK)
} catch {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
return
}
return {
sock: path,
container: process.env.HEADSCALE_CONTAINER,
}
}
async function checkOidc(config?: HeadscaleConfig) {
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
if (!rootKey) {
throw new Error('ROOT_API_KEY or API_KEY not set')
}
let issuer = process.env.OIDC_ISSUER
let client = process.env.OIDC_CLIENT_ID
let secret = process.env.OIDC_CLIENT_SECRET
if (
(issuer ?? client ?? secret)
&& !(issuer && client && secret)
&& !config
) {
throw new Error('OIDC environment variables are incomplete')
}
if ((!issuer || !client || !secret) && config) {
issuer = config.oidc?.issuer
client = config.oidc?.client_id
secret = config.oidc?.client_secret
if (!secret && config.oidc?.client_secret_path) {
try {
const data = await readFile(
config.oidc.client_secret_path,
'utf8',
)
if (data && data.length > 0) {
secret = data.trim()
}
} catch {}
}
}
if (
(issuer ?? client ?? secret)
&& !(issuer && client && secret)
) {
throw new Error('OIDC configuration is incomplete')
}
if (!issuer || !client || !secret) {
return
}
return {
issuer,
client,
secret,
rootKey,
disableKeyLogin,
}
}

View File

@ -0,0 +1,187 @@
// Handle the configuration loading for headscale.
// Functionally only used for reading and writing the configuration file.
// Availability checks and other configuration checks are done in the headplane
// configuration file that's adjacent to this one.
//
// Around the codebase, this is referred to as the config
// Refer to this file on juanfont/headscale for the default values:
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/config.go
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { type Document, parseDocument } from 'yaml'
import { z } from 'zod'
const HeadscaleConfig = z.object({
tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'),
tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'),
tls_letsencrypt_hostname: z.string().optional(),
tls_letsencrypt_listen: z.string().optional(),
tls_cert_path: z.string().optional(),
tls_key_path: z.string().optional(),
server_url: z.string().regex(/^https?:\/\//),
listen_addr: z.string(),
metrics_listen_addr: z.string().optional(),
grpc_listen_addr: z.string().default(':50443'),
grpc_allow_insecure: z.boolean().default(false),
disable_check_updates: z.boolean().default(false),
ephemeral_node_inactivity_timeout: z.string().default('120s'),
randomize_client_port: z.boolean().default(false),
acl_policy_path: z.string().optional(),
acme_email: z.string().optional(),
acme_url: z.string().optional(),
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
unix_socket_permission: z.string().default('0o770'),
tuning: z.object({
batch_change_delay: z.string().default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30),
}).optional(),
noise: z.object({
private_key_path: z.string(),
}),
log: z.object({
level: z.string().default('info'),
format: z.enum(['text', 'json']).default('text'),
}).default({ level: 'info', format: 'text' }),
logtail: z.object({
enabled: z.boolean().default(false),
}).default({ enabled: false }),
cli: z.object({
address: z.string().optional(),
api_key: z.string().optional(),
timeout: z.string().default('10s'),
insecure: z.boolean().default(false),
}).optional(),
prefixes: z.object({
allocation: z.enum(['sequential', 'random']).default('sequential'),
v4: z.string(),
v6: z.string(),
}),
dns_config: z.object({
override_local_dns: z.boolean().default(true),
nameservers: z.array(z.string()).default([]),
restricted_nameservers: z.record(z.array(z.string())).default({}),
domains: z.array(z.string()).default([]),
extra_records: z.array(z.object({
name: z.string(),
type: z.literal('A'),
value: z.string(),
})).default([]),
magic_dns: z.boolean().default(false),
base_domain: z.string().default('headscale.net'),
}),
oidc: z.object({
only_start_if_oidc_is_available: z.boolean().default(true),
issuer: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
client_secret_path: z.string().optional(),
scope: z.array(z.string()).default(['openid', 'profile', 'email']),
extra_params: z.record(z.string()).default({}),
allowed_domains: z.array(z.string()).optional(),
allowed_users: z.array(z.string()).optional(),
allowed_groups: z.array(z.string()).optional(),
strip_email_domain: z.boolean().default(true),
expiry: z.string().default('180d'),
use_expiry_from_token: z.boolean().default(false),
}).optional(),
database: z.union([
z.object({
type: z.literal('sqlite'),
debug: z.boolean().default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('sqlite3'),
debug: z.boolean().default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('postgres'),
debug: z.boolean().default(false),
postgres: z.object({
host: z.string(),
port: z.number(),
name: z.string(),
user: z.string(),
pass: z.string(),
ssl: z.boolean().default(false),
max_open_conns: z.number().default(10),
max_idle_conns: z.number().default(10),
conn_max_idle_time_secs: z.number().default(3600),
}),
}),
]),
derp: z.object({
server: z.object({
enabled: z.boolean().default(false),
region_id: z.number().optional(),
region_code: z.string().optional(),
region_name: z.string().optional(),
stun_listen_addr: z.string().optional(),
private_key_path: z.string().optional(),
ipv4: z.string().optional(),
ipv6: z.string().optional(),
automatically_add_embedded_derp_region: z.boolean().default(true),
}),
urls: z.array(z.string()).optional(),
paths: z.array(z.string()).optional(),
auto_update_enabled: z.boolean().default(true),
update_frequency: z.string().default('24h'),
}),
})
export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
export let configYaml: Document | undefined
export let config: HeadscaleConfig | undefined
export async function loadConfig() {
if (config) {
return config
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const data = await readFile(path, 'utf8')
configYaml = parseDocument(data)
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
return config
}
// This is so obscenely dangerous, please have a check around it
export async function patchConfig(partial: Record<string, unknown>) {
if (!configYaml || !config) {
throw new Error('Config not loaded')
}
for (const [key, value] of Object.entries(partial)) {
configYaml.setIn(key.split('.'), value)
}
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
await writeFile(path, configYaml.toString(), 'utf8')
}

View File

@ -1,31 +1,25 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-constant-condition */
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { getContext } from './config'
import { loadContext } from './config/headplane'
import { HeadscaleError, pull } from './headscale'
export async function sighupHeadscale() {
const context = await getContext()
if (!context.hasDockerSock) {
const context = await loadContext()
if (!context.docker) {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
const client = new Client('http://localhost', {
socketPath: '/var/run/docker.sock'
socketPath: context.docker.sock,
})
const container = process.env.HEADSCALE_CONTAINER
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`
path: `/v1.30/containers/${context.docker.container}/kill?signal=SIGHUP`,
})
if (!response.statusCode || response.statusCode !== 204) {
@ -34,23 +28,18 @@ export async function sighupHeadscale() {
}
export async function restartHeadscale() {
const context = await getContext()
if (!context.hasDockerSock) {
const context = await loadContext()
if (!context.docker) {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
const client = new Client('http://localhost', {
socketPath: '/var/run/docker.sock'
socketPath: context.docker.sock,
})
const container = process.env.HEADSCALE_CONTAINER
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/restart`
path: `/v1.30/containers/${context.docker.container}/restart`,
})
if (!response.statusCode || response.statusCode !== 204) {

View File

@ -1,4 +1,4 @@
import { getContext } from './config'
import { loadContext } from './config/headplane'
export class HeadscaleError extends Error {
status: number
@ -18,12 +18,12 @@ export class FatalError extends Error {
}
export async function pull<T>(url: string, key: string) {
const context = await getContext()
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`
}
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
@ -34,14 +34,14 @@ export async function pull<T>(url: string, key: string) {
}
export async function post<T>(url: string, key: string, body?: unknown) {
const context = await getContext()
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`
}
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
@ -52,13 +52,13 @@ export async function post<T>(url: string, key: string, body?: unknown) {
}
export async function del<T>(url: string, key: string) {
const context = await getContext()
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`
}
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {

View File

@ -37,7 +37,8 @@
"tailwindcss-react-aria-components": "^1.1.2",
"undici": "^6.16.1",
"usehooks-ts": "^3.1.0",
"yaml": "^2.4.2"
"yaml": "^2.4.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.9.2",

View File

@ -91,6 +91,9 @@ dependencies:
yaml:
specifier: ^2.4.2
version: 2.4.2
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies:
'@remix-run/dev':