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
17 changed files with 742 additions and 676 deletions
+83 -75
View File
@@ -2,30 +2,30 @@ import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIco
import { Form } from '@remix-run/react' import { Form } from '@remix-run/react'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { type Context } from '~/utils/config' import { HeadplaneContext } from '~/utils/config/headplane'
import { type SessionData } from '~/utils/sessions' import { type SessionData } from '~/utils/sessions'
import Menu from './Menu' import Menu from './Menu'
import TabLink from './TabLink' import TabLink from './TabLink'
type Properties = { interface Properties {
readonly data?: Context & { user?: SessionData['user'] }; readonly data?: HeadplaneContext & { user?: SessionData['user'] }
} }
type LinkProperties = { interface LinkProperties {
readonly href: string; readonly href: string
readonly text: string; readonly text: string
readonly isMenu?: boolean; readonly isMenu?: boolean
} }
function Link({ href, text, isMenu }: LinkProperties) { function Link({ href, text, isMenu }: LinkProperties) {
return ( return (
<a <a
href={href} href={href}
target='_blank' target="_blank"
rel='noreferrer' rel="noreferrer"
className={cn( 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} {text}
@@ -35,74 +35,82 @@ function Link({ href, text, isMenu }: LinkProperties) {
export default function Header({ data }: Properties) { export default function Header({ data }: Properties) {
return ( return (
<header className='bg-main-700 dark:bg-main-800 text-ui-50'> <header className="bg-main-700 dark:bg-main-800 text-ui-50">
<div className='container flex items-center justify-between py-4'> <div className="container flex items-center justify-between py-4">
<div className='flex items-center gap-x-2'> <div className="flex items-center gap-x-2">
<PaperAirplaneIcon className='w-6 h-6'/> <PaperAirplaneIcon className="w-6 h-6" />
<h1 className='text-2xl'>Headplane</h1> <h1 className="text-2xl">Headplane</h1>
</div> </div>
<div className='flex items-center gap-x-4'> <div className="flex items-center gap-x-4">
<Link href='https://tailscale.com/download' text='Download'/> <Link href="https://tailscale.com/download" text="Download" />
<Link href='https://github.com/tale/headplane' text='GitHub'/> <Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href='https://github.com/juanfont/headscale' text='Headscale'/> <Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data?.user ? ( {data?.user
<Menu> ? (
<Menu.Button className={cn( <Menu>
'rounded-full h-9 w-9', <Menu.Button className={cn(
'border border-main-600 dark:border-main-700', 'rounded-full h-9 w-9',
'hover:bg-main-600 dark:hover:bg-main-700' '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> <PersonIcon className="h-5 w-5 mt-0.5" />
<Menu.Items> </Menu.Button>
<Menu.Item className='text-right'> <Menu.Items>
<p className='font-bold'>{data.user.name}</p> <Menu.Item className="text-right">
<p>{data.user.email}</p> <p className="font-bold">{data.user.name}</p>
</Menu.Item> <p>{data.user.email}</p>
<Menu.Item className='text-right sm:hidden'> </Menu.Item>
<Link <Menu.Item className="text-right sm:hidden">
isMenu <Link
href='https://tailscale.com/download' isMenu
text='Download' href="https://tailscale.com/download"
/> text="Download"
</Menu.Item> />
<Menu.Item className='text-right sm:hidden'> </Menu.Item>
<Link <Menu.Item className="text-right sm:hidden">
isMenu <Link
href='https://github.com/tale/headplane' isMenu
text='GitHub' href="https://github.com/tale/headplane"
/> text="GitHub"
</Menu.Item> />
<Menu.Item className='text-right sm:hidden'> </Menu.Item>
<Link <Menu.Item className="text-right sm:hidden">
isMenu <Link
href='https://github.com/juanfont/headscale' isMenu
text='Headscale' href="https://github.com/juanfont/headscale"
/> text="Headscale"
</Menu.Item> />
<Menu.Item className='text-red-500 dark:text-red-400'> </Menu.Item>
<Form method='POST' action='/logout'> <Menu.Item className="text-red-500 dark:text-red-400">
<button type='submit' className='w-full text-right'> <Form method="POST" action="/logout">
Logout <button type="submit" className="w-full text-right">
</button> Logout
</Form> </button>
</Menu.Item> </Form>
</Menu.Items> </Menu.Item>
</Menu> </Menu.Items>
) : undefined} </Menu>
)
: undefined}
</div> </div>
</div> </div>
<nav className='container flex items-center gap-x-4 overflow-x-auto'> <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="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
<TabLink to='/users' name='Users' icon={<PeopleIcon 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?.acl.read
{data?.hasConfig ? ( ? (
<> <TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
<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}
</> {data?.config.read
) : undefined} ? (
<>
<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> </nav>
</header> </header>
) )
+14 -31
View File
@@ -4,62 +4,45 @@ import {
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration ScrollRestoration,
} from '@remix-run/react' } from '@remix-run/react'
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error'
import { Toaster } from '~/components/Toaster' import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url' import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config'
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { 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 = () => [ 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 }) { export function Layout({ children }: { readonly children: React.ReactNode }) {
return ( return (
<html lang='en'> <html lang="en">
<head> <head>
<meta charSet='utf-8'/> <meta charSet="utf-8" />
<meta name='viewport' content='width=device-width, initial-scale=1'/> <meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta/> <Meta />
<Links/> <Links />
</head> </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} {children}
<Toaster/> <Toaster />
<ScrollRestoration/> <ScrollRestoration />
<Scripts/> <Scripts />
</body> </body>
</html> </html>
) )
} }
export function ErrorBoundary() { export function ErrorBoundary() {
return <ErrorPopup/> return <ErrorPopup />
} }
export default function App() { export default function App() {
return <Outlet/> return <Outlet />
} }
+47 -45
View File
@@ -8,7 +8,7 @@ import { ClientOnly } from 'remix-utils/client-only'
import Link from '~/components/Link' import Link from '~/components/Link'
import Notice from '~/components/Notice' import Notice from '~/components/Notice'
import { cn } from '~/utils/cn' 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 { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
@@ -16,16 +16,16 @@ import Editor from './editor'
import Fallback from './fallback' import Fallback from './fallback'
export async function loader() { export async function loader() {
const context = await getContext() const context = await loadContext()
if (!context.hasAcl) { if (!context.acl.read) {
throw new Error('No ACL configuration is available') throw new Error('No ACL configuration is available')
} }
const { data, type } = await getAcl() const { data, type } = await loadAcl()
return { return {
hasAclWrite: context.hasAclWrite, hasAclWrite: context.acl.write,
currentAcl: data, currentAcl: data,
aclType: type aclType: type,
} }
} }
@@ -33,21 +33,21 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ success: false }, { return json({ success: false }, {
status: 401 status: 401,
}) })
} }
const context = await getContext() const context = await loadContext()
if (!context.hasAclWrite) { if (!context.acl.write) {
return json({ success: false }, { return json({ success: false }, {
status: 403 status: 403,
}) })
} }
const data = await request.json() as { acl: string } const data = await request.json() as { acl: string }
await patchAcl(data.acl) await patchAcl(data.acl)
if (context.hasDockerSock) { if (context.docker) {
await sighupHeadscale() await sighupHeadscale()
} }
@@ -60,26 +60,28 @@ export default function Page() {
return ( return (
<div> <div>
{data.hasAclWrite ? undefined : ( {data.hasAclWrite
<div className='mb-4'> ? undefined
<Notice> : (
The ACL policy file is readonly to Headplane. <div className="mb-4">
You will not be able to make changes here. <Notice>
</Notice> The ACL policy file is readonly to Headplane.
</div> 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) Access Control List (ACL)
</h1> </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. 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 You can find more information about the ACL file in the
{' '} {' '}
<Link <Link
to='https://tailscale.com/kb/1018/acls' to="https://tailscale.com/kb/1018/acls"
name='Tailscale ACL documentation' name="Tailscale ACL documentation"
> >
Tailscale ACL guide Tailscale ACL guide
</Link> </Link>
@@ -87,8 +89,8 @@ export default function Page() {
and the and the
{' '} {' '}
<Link <Link
to='https://headscale.net/acls' to="https://headscale.net/acls"
name='Headscale ACL documentation' name="Headscale ACL documentation"
> >
Headscale docs Headscale docs
</Link> </Link>
@@ -99,70 +101,70 @@ export default function Page() {
<TabList className={cn( <TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700', 'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden', 'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500' 'text-gray-400 dark:text-gray-500',
)} )}
> >
<Tab <Tab
id='edit' id="edit"
className={({ isSelected }) => cn( className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg', 'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2', 'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700', '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> <p>Edit file</p>
</Tab> </Tab>
<Tab <Tab
id='diff' id="diff"
className={({ isSelected }) => cn( className={({ isSelected }) => cn(
'px-4 py-2', 'px-4 py-2',
'focus:outline-none flex items-center gap-2', 'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700', '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> <p>Preview changes</p>
</Tab> </Tab>
<Tab <Tab
id='preview' id="preview"
className={({ isSelected }) => cn( className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg', 'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2', 'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700', '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> <p>Preview rules</p>
</Tab> </Tab>
</TabList> </TabList>
<TabPanel id='edit'> <TabPanel id="edit">
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}> <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> </ClientOnly>
</TabPanel> </TabPanel>
<TabPanel id='diff'> <TabPanel id="diff">
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}> <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> </ClientOnly>
</TabPanel> </TabPanel>
<TabPanel id='preview'> <TabPanel id="preview">
<div <div
className={cn( className={cn(
'border border-gray-200 dark:border-gray-700', 'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden', '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'/> <IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className='w-1/2 text-center mt-4'> <p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress. 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. It is a bit complicated to implement right now but hopefully it will be available soon.
</p> </p>
+91 -84
View File
@@ -9,7 +9,8 @@ import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch' import Switch from '~/components/Switch'
import TableList from '~/components/TableList' import TableList from '~/components/TableList'
import { cn } from '~/utils/cn' 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 { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData'
@@ -20,27 +21,26 @@ import RenameModal from './rename'
// We do not want to expose every config value // We do not want to expose every config value
export async function loader() { export async function loader() {
const context = await getContext() const context = await loadContext()
if (!context.hasConfig) { if (!context.config.read) {
throw new Error('No configuration is available') throw new Error('No configuration is available')
} }
const config = await getConfig() const config = await loadConfig()
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns ?? false, magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain, baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns ?? false, overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers ?? [], nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers ?? {}, splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains ?? [], searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records ?? [] extraRecords: config.dns_config.extra_records,
} }
return { return {
...dns, ...dns,
...context ...context,
} }
} }
@@ -48,14 +48,14 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) { if (!session.has('hsApiKey')) {
return json({ success: false }, { return json({ success: false }, {
status: 401 status: 401,
}) })
} }
const context = await getContext() const context = await loadContext()
if (!context.hasConfigWrite) { if (!context.config.write) {
return json({ success: false }, { return json({ success: false }, {
status: 403 status: 403,
}) })
} }
@@ -73,39 +73,41 @@ export default function Page() {
const [ns, setNs] = useState('') const [ns, setNs] = useState('')
return ( return (
<div className='flex flex-col gap-16 max-w-screen-lg'> <div className="flex flex-col gap-16 max-w-screen-lg">
{data.hasConfigWrite ? undefined : ( {data.config.write
<Notice> ? undefined
The Headscale configuration is read-only. You cannot make changes to the configuration : (
</Notice> <Notice>
)} The Headscale configuration is read-only. You cannot make changes to the configuration
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/> </Notice>
<div className='flex flex-col w-2/3'> )}
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1> <RenameModal name={data.baseDomain} disabled={!data.config.write} />
<p className='text-gray-700 dark:text-gray-300'> <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 Set the nameservers used by devices on the Tailnet
to resolve DNS queries. to resolve DNS queries.
</p> </p>
<div className='mt-4'> <div className="mt-4">
<div className='flex items-center justify-between mb-2'> <div className="flex items-center justify-between mb-2">
<h2 className='text-md font-medium opacity-80'> <h2 className="text-md font-medium opacity-80">
Global Nameservers Global Nameservers
</h2> </h2>
<div className='flex gap-2 items-center'> <div className="flex gap-2 items-center">
<span className='text-sm opacity-50'> <span className="text-sm opacity-50">
Override local DNS Override local DNS
</span> </span>
<Switch <Switch
label='Override local DNS' label="Override local DNS"
defaultSelected={localOverride} defaultSelected={localOverride}
isDisabled={!data.hasConfigWrite} isDisabled={!data.config.write}
onChange={() => { onChange={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride 'dns_config.override_local_dns': !localOverride,
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json' encType: 'application/json',
}) })
setLocalOverride(!localOverride) setLocalOverride(!localOverride)
@@ -117,22 +119,22 @@ export default function Page() {
{data.nameservers.map((ns, index) => ( {data.nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}> <TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p> <p className="font-mono text-sm">{ns}</p>
<Button <Button
className={cn( className={cn(
'text-sm', 'text-sm',
'text-red-600 dark:text-red-400', 'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300', '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={() => { onPress={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // 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', method: 'PATCH',
encType: 'application/json' encType: 'application/json',
}) })
}} }}
> >
@@ -140,45 +142,49 @@ export default function Page() {
</Button> </Button>
</TableList.Item> </TableList.Item>
))} ))}
{data.hasConfigWrite ? ( {data.config.write
<TableList.Item> ? (
<Input <TableList.Item>
type='text' <Input
className='font-mono text-sm bg-transparent w-full mr-2' type="text"
placeholder='Nameserver' className="font-mono text-sm bg-transparent w-full mr-2"
value={ns} placeholder="Nameserver"
onChange={event => { value={ns}
setNs(event.target.value) 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('')
}} }}
> />
Add {fetcher.state === 'idle'
</Button> ? (
) : ( <Button
<Spinner className='w-3 h-3 mr-0'/> className={cn(
)} 'text-sm font-semibold',
</TableList.Item> 'text-blue-600 dark:text-blue-400',
) : undefined} '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> </TableList>
{/* TODO: Split DNS and Custom A Records */} {/* TODO: Split DNS and Custom A Records */}
</div> </div>
@@ -187,22 +193,23 @@ export default function Page() {
<Domains <Domains
baseDomain={data.magicDns ? data.baseDomain : undefined} baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains} searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite} disabled={!data.config.write}
/> />
<div className='flex flex-col w-2/3'> <div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1> <h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className='text-gray-700 dark:text-gray-300 mb-4'> <p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device Automatically register domain names for each device
on the tailnet. Devices will be accessible at on the tailnet. Devices will be accessible at
{' '} {' '}
<Code> <Code>
[device].[user].{data.baseDomain} [device].[user].
{data.baseDomain}
</Code> </Code>
{' '} {' '}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/> <MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div> </div>
</div> </div>
) )
+5 -4
View File
@@ -7,7 +7,8 @@ import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code' import Code from '~/components/Code'
import { type Machine, type Route } from '~/types' import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn' 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 { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData' import { useLiveData } from '~/utils/useLiveData'
@@ -21,11 +22,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
]) ])
const context = await getContext() const context = await loadContext()
let magic: string | undefined let magic: string | undefined
if (context.hasConfig) { if (context.config.read) {
const config = await getConfig() const config = await loadConfig()
if (config.dns_config.magic_dns) { if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain magic = config.dns_config.base_domain
} }
+12 -12
View File
@@ -5,7 +5,7 @@ import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error' import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header' import Header from '~/components/Header'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { getContext } from '~/utils/config' import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale' import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions' import { destroySession, getSession } from '~/utils/sessions'
@@ -24,8 +24,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirect('/login', { return redirect('/login', {
headers: { headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention // 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 throw error
} }
const context = await getContext() const context = await loadContext()
return { return {
...context, ...context,
user: session.get('user') user: session.get('user'),
} }
} }
@@ -47,19 +47,19 @@ export default function Layout() {
return ( return (
<> <>
<ProgressBar <ProgressBar
aria-label='Loading...' aria-label="Loading..."
> >
<div <div
className={cn( className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1', 'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0', 'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100' nav.state === 'loading' && 'animate-loading opacity-100',
)} )}
/> />
</ProgressBar> </ProgressBar>
<Header data={data}/> <Header data={data} />
<main className='container mx-auto overscroll-contain mt-4 mb-24'> <main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet/> <Outlet />
</main> </main>
</> </>
) )
@@ -68,8 +68,8 @@ export default function Layout() {
export function ErrorBoundary() { export function ErrorBoundary() {
return ( return (
<> <>
<Header/> <Header />
<ErrorPopup type='embedded'/> <ErrorPopup type="embedded" />
</> </>
) )
} }
+2 -2
View File
@@ -2,12 +2,12 @@ import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
import Card from '~/components/Card' import Card from '~/components/Card'
import Link from '~/components/Link' import Link from '~/components/Link'
import { type Context } from '~/utils/config' import { HeadplaneContext } from '~/utils/config/headplane'
import Add from './add' import Add from './add'
interface Props { interface Props {
readonly oidc: NonNullable<Context['oidcConfig']> readonly oidc: NonNullable<HeadplaneContext['oidc']>
readonly magic: string | undefined readonly magic: string | undefined
} }
+8 -7
View File
@@ -12,7 +12,8 @@ import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster'
import { type Machine, type User } from '~/types' import { type Machine, type User } from '~/types'
import { cn } from '~/utils/cn' 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 { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData' 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), machines: machines.nodes.filter(machine => machine.user.id === user.id),
})) }))
const context = await getContext() const context = await loadContext()
let magic: string | undefined let magic: string | undefined
if (context.hasConfig) { if (context.config.read) {
const config = await getConfig() const config = await loadConfig()
if (config.dns_config.magic_dns) { if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain magic = config.dns_config.base_domain
} }
} }
return { return {
oidcConfig: context.oidcConfig, oidc: context.oidc,
magic, magic,
users, users,
} }
@@ -170,10 +171,10 @@ export default function Page() {
Manage the users in your network and their permissions. Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership. Tip: You can drag machines between users to change ownership.
</p> </p>
{data.oidcConfig {data.oidc
? ( ? (
<Oidc <Oidc
oidc={data.oidcConfig} oidc={data.oidc}
magic={data.magic} magic={data.magic}
/> />
) )
+20 -26
View File
@@ -7,7 +7,7 @@ import Card from '~/components/Card'
import Code from '~/components/Code' import Code from '~/components/Code'
import TextField from '~/components/TextField' import TextField from '~/components/TextField'
import { type Key } from '~/types' import { type Key } from '~/types'
import { getContext } from '~/utils/config' import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale' import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc' import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions' import { commitSession, getSession } from '~/utils/sessions'
@@ -23,31 +23,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
}) })
} }
const context = await getContext() const context = await loadContext()
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)) { // Only set if OIDC is properly enabled anyways
throw new Error('An invalid OIDC configuration was provided') if (context.oidc?.disableKeyLogin) {
return startOidc(
context.oidc.issuer,
context.oidc.client,
request,
)
} }
const data = { return {
oidc: issuer, oidc: context.oidc?.issuer,
apiKey: normal !== 'true', 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) { export async function action({ request }: ActionFunctionArgs) {
@@ -55,13 +45,17 @@ export async function action({ request }: ActionFunctionArgs) {
const oidcStart = formData.get('oidc-start') const oidcStart = formData.get('oidc-start')
if (oidcStart) { if (oidcStart) {
const context = await getContext() const context = await loadContext()
const issuer = context.oidcConfig?.issuer const issuer = context.oidc?.issuer
const id = context.oidcConfig?.client 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 // We know it exists here because this action only happens on OIDC
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // 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')) const apiKey = String(formData.get('api-key'))
+9 -6
View File
@@ -1,15 +1,18 @@
import { type LoaderFunctionArgs } from '@remix-run/node' import { type LoaderFunctionArgs } from '@remix-run/node'
import { getContext } from '~/utils/config' import { loadContext } from '~/utils/config/headplane'
import { finishOidc } from '~/utils/oidc' import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const context = await getContext() const context = await loadContext()
const oidc = context.oidcConfig if (!context.oidc) {
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided') 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,
)
} }
-353
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
}
+240
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,
}
}
+187
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')
}
+9 -20
View File
@@ -1,31 +1,25 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
/* eslint-disable no-constant-condition */ /* eslint-disable no-constant-condition */
import { setTimeout } from 'node:timers/promises' import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici' import { Client } from 'undici'
import { getContext } from './config' import { loadContext } from './config/headplane'
import { HeadscaleError, pull } from './headscale' import { HeadscaleError, pull } from './headscale'
export async function sighupHeadscale() { export async function sighupHeadscale() {
const context = await getContext() const context = await loadContext()
if (!context.hasDockerSock) { if (!context.docker) {
return return
} }
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
const client = new Client('http://localhost', { 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({ const response = await client.request({
method: 'POST', 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) { if (!response.statusCode || response.statusCode !== 204) {
@@ -34,23 +28,18 @@ export async function sighupHeadscale() {
} }
export async function restartHeadscale() { export async function restartHeadscale() {
const context = await getContext() const context = await loadContext()
if (!context.hasDockerSock) { if (!context.docker) {
return return
} }
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
const client = new Client('http://localhost', { 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({ const response = await client.request({
method: 'POST', method: 'POST',
path: `/v1.30/containers/${container}/restart` path: `/v1.30/containers/${context.docker.container}/restart`,
}) })
if (!response.statusCode || response.statusCode !== 204) { if (!response.statusCode || response.statusCode !== 204) {
+10 -10
View File
@@ -1,4 +1,4 @@
import { getContext } from './config' import { loadContext } from './config/headplane'
export class HeadscaleError extends Error { export class HeadscaleError extends Error {
status: number status: number
@@ -18,12 +18,12 @@ export class FatalError extends Error {
} }
export async function pull<T>(url: string, key: string) { export async function pull<T>(url: string, key: string) {
const context = await getContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
headers: { headers: {
Authorization: `Bearer ${key}` Authorization: `Bearer ${key}`,
} },
}) })
if (!response.ok) { 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) { export async function post<T>(url: string, key: string, body?: unknown) {
const context = await getContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST', method: 'POST',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
headers: { headers: {
Authorization: `Bearer ${key}` Authorization: `Bearer ${key}`,
} },
}) })
if (!response.ok) { 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) { export async function del<T>(url: string, key: string) {
const context = await getContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
Authorization: `Bearer ${key}` Authorization: `Bearer ${key}`,
} },
}) })
if (!response.ok) { if (!response.ok) {
+2 -1
View File
@@ -37,7 +37,8 @@
"tailwindcss-react-aria-components": "^1.1.2", "tailwindcss-react-aria-components": "^1.1.2",
"undici": "^6.16.1", "undici": "^6.16.1",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0",
"yaml": "^2.4.2" "yaml": "^2.4.2",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "^2.9.2", "@remix-run/dev": "^2.9.2",
+3
View File
@@ -91,6 +91,9 @@ dependencies:
yaml: yaml:
specifier: ^2.4.2 specifier: ^2.4.2
version: 2.4.2 version: 2.4.2
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies: devDependencies:
'@remix-run/dev': '@remix-run/dev':