mirror of
https://github.com/tale/headplane.git
synced 2026-09-02 02:08:44 +00:00
feat: use strictly typed configs and context
This commit is contained in:
+83
-75
@@ -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
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+3
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user