Compare commits

...

14 Commits

Author SHA1 Message Date
Aarnav Tale 1a30185047 chore: v0.1.6 2024-05-22 14:15:05 -04:00
Aarnav Tale c7e59b137c docs: solidify DOCKER_SOCK and ROOT_API_KEY usage 2024-05-22 12:43:42 -04:00
Aarnav Tale 694b22f205 style: cleanup oidc spaghetti 2024-05-21 23:57:03 -04:00
Aarnav Tale 84855d9d51 chore: update pnpm and enforce engines 2024-05-21 23:50:43 -04:00
Aarnav Tale 6bea3b2a55 fix: user grid on mobile should be full width 2024-05-21 17:23:33 -04:00
Aarnav Tale 2ee1adf142 chore: delete entry files because they aren't modified 2024-05-21 17:22:11 -04:00
Aarnav Tale 06d7d1ccad feat: add support for split dns 2024-05-21 17:21:14 -04:00
Aarnav Tale 2aae6bfc06 chore: v0.1.5 2024-05-20 14:08:10 -04:00
Aarnav Tale 6794b1b2dd fix: don't log the context 2024-05-20 14:06:38 -04:00
Aarnav Tale a6582ef835 fix: button responders on acl page never worked 2024-05-20 14:05:27 -04:00
Aarnav Tale 3e51e4861d feat: use strictly typed configs and context 2024-05-20 14:05:09 -04:00
Aarnav Tale 0a12cdb3d6 fix: call button onPress even without state handler 2024-05-20 14:02:04 -04:00
Aarnav Tale 78698dcbba fix: DISABLE_API_KEY_LOGIN should only work when set to true 2024-05-19 19:15:29 -04:00
Aarnav Tale 260fb870e1 fix: when renaming users start with old names 2024-05-15 23:46:15 -04:00
31 changed files with 6741 additions and 5290 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
API_KEY=abcdefghijklmnopqrstuvwxyz
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale
+12
View File
@@ -1,3 +1,15 @@
### 0.1.5 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
### 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
- Fixed button responders not actually being invoked (should fix the ACL page).
### 0.1.4 (May 15, 2024)
- Users can now be created, renamed, and deleted on the users page.
+8 -7
View File
@@ -4,8 +4,8 @@ import { Button as AriaButton } from 'react-aria-components'
import { cn } from '~/utils/cn'
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly variant?: 'heavy' | 'light';
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
readonly variant?: 'heavy' | 'light'
}
export default function Button(properties: ButtonProperties) {
@@ -24,13 +24,14 @@ export default function Button(properties: ButtonProperties) {
? 'text-white'
: 'text-ui-700 dark:text-ui-300',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
properties.className,
)}
// If control is passed, set the state value
onPress={properties.control ? () => {
properties.control?.[1](true)
} : undefined}
onPress={properties.control
? () => {
properties.control?.[1](true)
}
: properties.onPress}
/>
)
}
+83 -75
View File
@@ -2,30 +2,30 @@ import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIco
import { Form } from '@remix-run/react'
import { cn } from '~/utils/cn'
import { type Context } from '~/utils/config'
import { HeadplaneContext } from '~/utils/config/headplane'
import { type SessionData } from '~/utils/sessions'
import Menu from './Menu'
import TabLink from './TabLink'
type Properties = {
readonly data?: Context & { user?: SessionData['user'] };
interface Properties {
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
}
type LinkProperties = {
readonly href: string;
readonly text: string;
readonly isMenu?: boolean;
interface LinkProperties {
readonly href: string
readonly text: string
readonly isMenu?: boolean
}
function Link({ href, text, isMenu }: LinkProperties) {
return (
<a
href={href}
target='_blank'
rel='noreferrer'
target="_blank"
rel="noreferrer"
className={cn(
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block'
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
)}
>
{text}
@@ -35,74 +35,82 @@ function Link({ href, text, isMenu }: LinkProperties) {
export default function Header({ data }: Properties) {
return (
<header className='bg-main-700 dark:bg-main-800 text-ui-50'>
<div className='container flex items-center justify-between py-4'>
<div className='flex items-center gap-x-2'>
<PaperAirplaneIcon className='w-6 h-6'/>
<h1 className='text-2xl'>Headplane</h1>
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PaperAirplaneIcon className="w-6 h-6" />
<h1 className="text-2xl">Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<Link href='https://tailscale.com/download' text='Download'/>
<Link href='https://github.com/tale/headplane' text='GitHub'/>
<Link href='https://github.com/juanfont/headscale' text='Headscale'/>
{data?.user ? (
<Menu>
<Menu.Button className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700'
)}
>
<PersonIcon className='h-5 w-5 mt-0.5'/>
</Menu.Button>
<Menu.Items>
<Menu.Item className='text-right'>
<p className='font-bold'>{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://tailscale.com/download'
text='Download'
/>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://github.com/tale/headplane'
text='GitHub'
/>
</Menu.Item>
<Menu.Item className='text-right sm:hidden'>
<Link
isMenu
href='https://github.com/juanfont/headscale'
text='Headscale'
/>
</Menu.Item>
<Menu.Item className='text-red-500 dark:text-red-400'>
<Form method='POST' action='/logout'>
<button type='submit' className='w-full text-right'>
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
) : undefined}
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data?.user
? (
<Menu>
<Menu.Button className={cn(
'rounded-full h-9 w-9',
'border border-main-600 dark:border-main-700',
'hover:bg-main-600 dark:hover:bg-main-700',
)}
>
<PersonIcon className="h-5 w-5 mt-0.5" />
</Menu.Button>
<Menu.Items>
<Menu.Item className="text-right">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://tailscale.com/download"
text="Download"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/tale/headplane"
text="GitHub"
/>
</Menu.Item>
<Menu.Item className="text-right sm:hidden">
<Link
isMenu
href="https://github.com/juanfont/headscale"
text="Headscale"
/>
</Menu.Item>
<Menu.Item className="text-red-500 dark:text-red-400">
<Form method="POST" action="/logout">
<button type="submit" className="w-full text-right">
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
)
: undefined}
</div>
</div>
<nav className='container flex items-center gap-x-4 overflow-x-auto'>
<TabLink to='/machines' name='Machines' icon={<ServerIcon className='w-4 h-4'/>}/>
<TabLink to='/users' name='Users' icon={<PeopleIcon className='w-4 h-4'/>}/>
{data?.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockIcon className='w-4 h-4'/>}/> : undefined}
{data?.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeIcon className='w-4 h-4'/>}/>
<TabLink to='/settings' name='Settings' icon={<GearIcon className='w-4 h-4'/>}/>
</>
) : undefined}
<nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
{data?.acl.read
? (
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
)
: undefined}
{data?.config.read
? (
<>
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
</>
)
: undefined}
</nav>
</header>
)
+43
View File
@@ -0,0 +1,43 @@
import { ReactNode } from 'react'
import {
Button as AriaButton,
Tooltip as AriaTooltip,
TooltipTrigger,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
interface Props {
children: ReactNode
className?: string
}
function Tooltip({ children }: Props) {
return (
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
}
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton {...props} />
)
}
function Body({ children, className }: Props) {
return (
<AriaTooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
>
{children}
</AriaTooltip>
)
}
export default Object.assign(Tooltip, { Button, Body })
-18
View File
@@ -1,18 +0,0 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from '@remix-run/react'
import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser/>
</StrictMode>
)
})
-137
View File
@@ -1,137 +0,0 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from 'node:stream'
import type { EntryContext } from '@remix-run/node'
import { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
// eslint-disable-next-line @typescript-eslint/naming-convention
const ABORT_DELAY = 5000
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return isbot(request.headers.get('user-agent') ?? '')
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
}
async function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
setTimeout(abort, ABORT_DELAY)
})
}
async function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
setTimeout(abort, ABORT_DELAY)
})
}
+14 -31
View File
@@ -4,62 +4,45 @@ import {
Meta,
Outlet,
Scripts,
ScrollRestoration
ScrollRestoration,
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config'
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }
{ name: 'description', content: 'A frontend for the headscale coordination server' },
]
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }
{ rel: 'stylesheet', href: stylesheet },
]
export async function loader() {
const context = await getContext()
registerConfigWatcher()
if (context.headscaleUrl.length === 0) {
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
}
if (!process.env.COOKIE_SECRET) {
throw new Error('The COOKIE_SECRET environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
export function Layout({ children }: { readonly children: React.ReactNode }) {
return (
<html lang='en'>
<html lang="en">
<head>
<meta charSet='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1'/>
<Meta/>
<Links/>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className='overscroll-none dark:bg-ui-950 dark:text-ui-50'>
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
{children}
<Toaster/>
<ScrollRestoration/>
<Scripts/>
<Toaster />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export function ErrorBoundary() {
return <ErrorPopup/>
return <ErrorPopup />
}
export default function App() {
return <Outlet/>
return <Outlet />
}
+61 -52
View File
@@ -13,16 +13,16 @@ import { toast } from '~/components/Toaster'
import Fallback from './fallback'
type EditorProperties = {
readonly acl: string;
readonly setAcl: (acl: string) => void;
readonly mode: 'edit' | 'diff';
interface EditorProperties {
readonly acl: string
readonly setAcl: (acl: string) => void
readonly mode: 'edit' | 'diff'
readonly data: {
hasAclWrite: boolean;
currentAcl: string;
aclType: string;
};
hasAclWrite: boolean
currentAcl: string
aclType: string
}
}
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
@@ -36,7 +36,7 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
theme.addEventListener('change', theme => {
theme.addEventListener('change', (theme) => {
setLight(theme.matches)
})
@@ -48,66 +48,75 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
<>
<div className={clsx(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden'
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className='overflow-y-scroll h-editor text-sm'>
{loading ? (
<Fallback acl={acl} where='client'/>
) : (
mode === 'edit' ? (
<CodeMirror
value={acl}
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
) : (
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation='a-b'
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[aclType]}
/>
<CodeMirrorMerge.Modified
readOnly
value={acl}
extensions={[aclType]}
/>
</CodeMirrorMerge>
)
)}
<div className="overflow-y-scroll h-editor text-sm">
{loading
? (
<Fallback acl={acl} where="client" />
)
: (
mode === 'edit'
? (
<CodeMirror
value={acl}
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={(value) => {
setAcl(value)
}}
/>
)
: (
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation="a-b"
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[aclType]}
/>
<CodeMirrorMerge.Modified
readOnly
value={acl}
extensions={[aclType]}
/>
</CodeMirrorMerge>
)
)}
</div>
</div>
<Button
variant='heavy'
className='mr-2'
variant="heavy"
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => {
fetcher.submit({
acl
acl,
}, {
method: 'PATCH',
encType: 'application/json'
encType: 'application/json',
})
toast('Updated tailnet ACL policy')
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button onPress={() => {
setAcl(data.currentAcl)
}}
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl}
onPress={() => {
setAcl(data.currentAcl)
}}
>
Discard Changes
</Button>
+47 -45
View File
@@ -8,7 +8,7 @@ import { ClientOnly } from 'remix-utils/client-only'
import Link from '~/components/Link'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { getAcl, getContext, patchAcl } from '~/utils/config'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
@@ -16,16 +16,16 @@ import Editor from './editor'
import Fallback from './fallback'
export async function loader() {
const context = await getContext()
if (!context.hasAcl) {
const context = await loadContext()
if (!context.acl.read) {
throw new Error('No ACL configuration is available')
}
const { data, type } = await getAcl()
const { data, type } = await loadAcl()
return {
hasAclWrite: context.hasAclWrite,
hasAclWrite: context.acl.write,
currentAcl: data,
aclType: type
aclType: type,
}
}
@@ -33,21 +33,21 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
status: 401,
})
}
const context = await getContext()
if (!context.hasAclWrite) {
const context = await loadContext()
if (!context.acl.write) {
return json({ success: false }, {
status: 403
status: 403,
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.hasDockerSock) {
if (context.docker) {
await sighupHeadscale()
}
@@ -60,26 +60,28 @@ export default function Page() {
return (
<div>
{data.hasAclWrite ? undefined : (
<div className='mb-4'>
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
</div>
)}
{data.hasAclWrite
? undefined
: (
<div className="mb-4">
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
</div>
)}
<h1 className='text-2xl font-medium mb-4'>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className='mb-4 max-w-prose'>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to='https://tailscale.com/kb/1018/acls'
name='Tailscale ACL documentation'
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
@@ -87,8 +89,8 @@ export default function Page() {
and the
{' '}
<Link
to='https://headscale.net/acls'
name='Headscale ACL documentation'
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
@@ -99,70 +101,70 @@ export default function Page() {
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500'
'text-gray-400 dark:text-gray-500',
)}
>
<Tab
id='edit'
id="edit"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<PencilIcon className='w-5 h-5'/>
<PencilIcon className="w-5 h-5" />
<p>Edit file</p>
</Tab>
<Tab
id='diff'
id="diff"
className={({ isSelected }) => cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<EyeIcon className='w-5 h-5'/>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id='preview'
id="preview"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<BeakerIcon className='w-5 h-5'/>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id='edit'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
<TabPanel id="edit">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" />
)}
</ClientOnly>
</TabPanel>
<TabPanel id='diff'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
<TabPanel id="diff">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" />
)}
</ClientOnly>
</TabPanel>
<TabPanel id='preview'>
<TabPanel id="preview">
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center'
'p-16 flex flex-col items-center justify-center',
)}
>
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='w-1/2 text-center mt-4'>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
</p>
@@ -0,0 +1,162 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import TextField from '~/components/TextField'
import Tooltip from '~/components/Tooltip'
import { cn } from '~/utils/cn'
interface Props {
nameservers: Record<string, string[]>
}
export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit()
const [split, setSplit] = useState(false)
const [ns, setNs] = useState('')
const [domain, setDomain] = useState('')
return (
<Dialog>
<Dialog.Button>
Add nameserver
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add nameserver
</Dialog.Title>
<Dialog.Text className="font-semibold">
Nameserver
</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!ns) return
if (split) {
const splitNs: Record<string, string[]> = {}
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue
splitNs[key] = value
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns)
} else {
splitNs[domain] = [ns]
}
submit({
'dns_config.restricted_nameservers': splitNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const globalNs = nameservers.global
globalNs.push(ns)
submit({
'dns_config.nameservers': globalNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
}
setNs('')
setDomain('')
setSplit(false)
close()
}}
>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS
(Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => { setSplit(!split) }}
/>
</div>
{split
? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries
matching this suffix should use the nameserver.
</Dialog.Text>
</>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { Button } from 'react-aria-components'
import Link from '~/components/Link'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import AddNameserver from './dialogs/nameserver'
interface Props {
nameservers: Record<string, string[]>
override: boolean
isDisabled: boolean
}
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p className="text-gray-700 dark:text-gray-300">
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map(key => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers[key]}
override={override}
name={key}
/>
))}
{isDisabled
? undefined
: (
<AddNameserver nameservers={nameservers} />
)}
</div>
</div>
)
}
interface ListProps {
isGlobal: boolean
isDisabled: boolean
nameservers: string[]
name: string
override: boolean
}
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
const [localOverride, setLocalOverride] = useState(override)
const submit = useSubmit()
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
{isGlobal
? (
<div className="flex gap-2 items-center">
<span className="text-sm opacity-50">
Override local DNS
</span>
<Switch
label="Override local DNS"
defaultSelected={localOverride}
isDisabled={isDisabled}
onChange={() => {
submit({
'dns_config.override_local_dns': !localOverride,
}, {
method: 'PATCH',
encType: 'application/json',
})
setLocalOverride(!localOverride)
}}
/>
</div>
)
: undefined}
</div>
<TableList>
{nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit({
'dns_config.nameservers': nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns_config.restricted_nameservers."${name}"`
submit({
[key]: nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
}
}}
>
Remove
</Button>
</TableList.Item>
))}
</TableList>
</div>
)
}
+46 -142
View File
@@ -1,46 +1,41 @@
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { Button, Input } from 'react-aria-components'
import { json, useLoaderData } from '@remix-run/react'
import Code from '~/components/Code'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Domains from './domains'
import MagicModal from './magic'
import Nameservers from './nameservers'
import RenameModal from './rename'
// We do not want to expose every config value
export async function loader() {
const context = await getContext()
if (!context.hasConfig) {
const context = await loadContext()
if (!context.config.read) {
throw new Error('No configuration is available')
}
const config = await getConfig()
const config = await loadConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns ?? false,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns ?? false,
nameservers: config.dns_config.nameservers ?? [],
splitDns: config.dns_config.restricted_nameservers ?? {},
searchDomains: config.dns_config.domains ?? [],
extraRecords: config.dns_config.extra_records ?? []
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records,
}
return {
...dns,
...context
...context,
}
}
@@ -48,14 +43,14 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
status: 401,
})
}
const context = await getContext()
if (!context.hasConfigWrite) {
const context = await loadContext()
if (!context.config.write) {
return json({ success: false }, {
status: 403
status: 403,
})
}
@@ -68,141 +63,50 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
const [ns, setNs] = useState('')
const allNs: Record<string, string[]> = {}
for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key]
}
allNs.global = data.nameservers
return (
<div className='flex flex-col gap-16 max-w-screen-lg'>
{data.hasConfigWrite ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
<p className='text-gray-700 dark:text-gray-300'>
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
</p>
<div className='mt-4'>
<div className='flex items-center justify-between mb-2'>
<h2 className='text-md font-medium opacity-80'>
Global Nameservers
</h2>
<div className='flex gap-2 items-center'>
<span className='text-sm opacity-50'>
Override local DNS
</span>
<Switch
label='Override local DNS'
defaultSelected={localOverride}
isDisabled={!data.hasConfigWrite}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride
}, {
method: 'PATCH',
encType: 'application/json'
})
setLocalOverride(!localOverride)
}}
/>
</div>
</div>
<TableList>
{data.nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
!data.hasConfigWrite && 'opacity-50 cursor-not-allowed'
)}
isDisabled={!data.hasConfigWrite}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
</TableList.Item>
))}
{data.hasConfigWrite ? (
<TableList.Item>
<Input
type='text'
className='font-mono text-sm bg-transparent w-full mr-2'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className={cn(
'text-sm font-semibold',
'text-blue-600 dark:text-blue-400',
'hover:text-blue-700 dark:hover:text-blue-300',
ns.length === 0 && 'opacity-50 cursor-not-allowed'
)}
isDisabled={ns.length === 0}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNs('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
) : undefined}
</TableList>
{/* TODO: Split DNS and Custom A Records */}
</div>
</div>
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write
? undefined
: (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers
nameservers={allNs}
override={data.overrideLocal}
isDisabled={!data.config.write}
/>
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
disabled={!data.config.write}
/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
<p className='text-gray-700 dark:text-gray-300 mb-4'>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Automatically register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].{data.baseDomain}
[device].[user].
{data.baseDomain}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
</div>
</div>
)
+5 -4
View File
@@ -7,7 +7,8 @@ import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
@@ -21,11 +22,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
])
const context = await getContext()
const context = await loadContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
+12 -12
View File
@@ -5,7 +5,7 @@ import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header'
import { cn } from '~/utils/cn'
import { getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import { destroySession, getSession } from '~/utils/sessions'
@@ -24,8 +24,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
'Set-Cookie': await destroySession(session),
},
})
}
@@ -33,10 +33,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
throw error
}
const context = await getContext()
const context = await loadContext()
return {
...context,
user: session.get('user')
user: session.get('user'),
}
}
@@ -47,19 +47,19 @@ export default function Layout() {
return (
<>
<ProgressBar
aria-label='Loading...'
aria-label="Loading..."
>
<div
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1',
'bg-blue-500 dark:bg-blue-400 opacity-0',
nav.state === 'loading' && 'animate-loading opacity-100'
nav.state === 'loading' && 'animate-loading opacity-100',
)}
/>
</ProgressBar>
<Header data={data}/>
<main className='container mx-auto overscroll-contain mt-4 mb-24'>
<Outlet/>
<Header data={data} />
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
)
@@ -68,8 +68,8 @@ export default function Layout() {
export function ErrorBoundary() {
return (
<>
<Header/>
<ErrorPopup type='embedded'/>
<Header />
<ErrorPopup type="embedded" />
</>
)
}
+2 -2
View File
@@ -2,12 +2,12 @@ import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
import Card from '~/components/Card'
import Link from '~/components/Link'
import { type Context } from '~/utils/config'
import { HeadplaneContext } from '~/utils/config/headplane'
import Add from './add'
interface Props {
readonly oidc: NonNullable<Context['oidcConfig']>
readonly oidc: NonNullable<HeadplaneContext['oidc']>
readonly magic: string | undefined
}
+1 -1
View File
@@ -15,7 +15,7 @@ interface Props {
export default function Rename({ username, magic }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
const [newName, setNewName] = useState('')
const [newName, setNewName] = useState(username)
return (
<>
+10 -9
View File
@@ -12,7 +12,8 @@ import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type User } from '~/types'
import { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
@@ -35,18 +36,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
machines: machines.nodes.filter(machine => machine.user.id === user.id),
}))
const context = await getContext()
const context = await loadContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
}
return {
oidcConfig: context.oidcConfig,
oidc: context.oidc,
magic,
users,
}
@@ -170,10 +171,10 @@ export default function Page() {
Manage the users in your network and their permissions.
Tip: You can drag machines between users to change ownership.
</p>
{data.oidcConfig
{data.oidc
? (
<Oidc
oidc={data.oidcConfig}
oidc={data.oidc}
magic={data.magic}
/>
)
@@ -209,7 +210,7 @@ interface UserProps {
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-2 gap-4 auto-rows-min">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
<UserCard
key={user.id}
@@ -265,7 +266,7 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
})
}}
>
<div className="grid grid-cols-2 gap-4 auto-rows-min">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
<UserCard
key={user.id}
+14 -27
View File
@@ -7,7 +7,7 @@ import Card from '~/components/Card'
import Code from '~/components/Code'
import TextField from '~/components/TextField'
import { type Key } from '~/types'
import { getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
@@ -23,31 +23,17 @@ export async function loader({ request }: LoaderFunctionArgs) {
})
}
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
const secret = context.oidcConfig?.secret
const normal = process.env.DISABLE_API_KEY_LOGIN
const context = await loadContext()
if (issuer && (!id || !secret)) {
throw new Error('An invalid OIDC configuration was provided')
// Only set if OIDC is properly enabled anyways
if (context.oidc?.disableKeyLogin) {
return startOidc(context.oidc, request)
}
const data = {
oidc: issuer,
apiKey: normal === undefined,
return {
oidc: context.oidc?.issuer,
apiKey: !context.oidc?.disableKeyLogin,
}
if (!data.oidc && !data.apiKey) {
throw new Error('No authentication method is enabled')
}
if (data.oidc && !data.apiKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(data.oidc, id!, request)
}
return data
}
export async function action({ request }: ActionFunctionArgs) {
@@ -55,13 +41,14 @@ export async function action({ request }: ActionFunctionArgs) {
const oidcStart = formData.get('oidc-start')
if (oidcStart) {
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
const context = await loadContext()
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
// We know it exists here because this action only happens on OIDC
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(issuer!, id!, request)
return startOidc(context.oidc, request)
}
const apiKey = String(formData.get('api-key'))
+4 -6
View File
@@ -1,15 +1,13 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { getContext } from '~/utils/config'
import { loadContext } from '~/utils/config/headplane'
import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const context = await getContext()
const oidc = context.oidcConfig
if (!oidc) {
const context = await loadContext()
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
return finishOidc(oidc.issuer, oidc.client, oidc.secret, request)
return finishOidc(context.oidc, 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
}
+269
View File
@@ -0,0 +1,269 @@
// 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?: {
url: string
sock: boolean
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),
}
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 ?? 'unix:///var/run/docker.sock'
let url: URL | undefined
try {
url = new URL(path)
} catch {
return
}
// The API is available as an HTTP endpoint
if (url.protocol === 'tcp:') {
url.protocol = 'http:'
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
await access(path, constants.R_OK)
} catch {
return
}
}
if (url.protocol === 'http:') {
try {
await fetch(new URL('/v1.30/version', url).href)
} catch {
return
}
}
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
return
}
return {
url: url.href,
sock: url.protocol === 'unix:',
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,
}
}
+215
View File
@@ -0,0 +1,215 @@
// 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)) {
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const path = []
let temp = ''
let inQuote = false
for (const element of key) {
if (element === '"') {
inQuote = !inQuote
}
if (element === '.' && !inQuote) {
path.push(temp.replaceAll('"', ''))
temp = ''
continue
}
temp += element
}
// Push the remaining element
path.push(temp.replaceAll('"', ''))
configYaml.setIn(path, value)
}
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
await writeFile(path, configYaml.toString(), 'utf8')
}
+23 -29
View File
@@ -1,31 +1,26 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-constant-condition */
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { getContext } from './config'
import { loadContext } from './config/headplane'
import { HeadscaleError, pull } from './headscale'
export async function sighupHeadscale() {
const context = await getContext()
if (!context.hasDockerSock) {
const context = await loadContext()
if (!context.docker) {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
// Supports the DOCKER_SOCK environment variable
const client = context.docker.sock
? new Client('http://localhost', {
socketPath: context.docker.url,
})
: new Client(context.docker.url)
const client = new Client('http://localhost', {
socketPath: '/var/run/docker.sock'
})
const container = process.env.HEADSCALE_CONTAINER
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`
path: `/v1.30/containers/${context.docker.container}/kill?signal=SIGHUP`,
})
if (!response.statusCode || response.statusCode !== 204) {
@@ -34,23 +29,21 @@ export async function sighupHeadscale() {
}
export async function restartHeadscale() {
const context = await getContext()
if (!context.hasDockerSock) {
const context = await loadContext()
if (!context.docker) {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
// Supports the DOCKER_SOCK environment variable
const client = context.docker.sock
? new Client('http://localhost', {
socketPath: context.docker.url,
})
: new Client(context.docker.url)
const client = new Client('http://localhost', {
socketPath: '/var/run/docker.sock'
})
const container = process.env.HEADSCALE_CONTAINER
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/restart`
path: `/v1.30/containers/${context.docker.container}/restart`,
})
if (!response.statusCode || response.statusCode !== 204) {
@@ -59,14 +52,15 @@ export async function restartHeadscale() {
// Wait for Headscale to restart before continuing
let attempts = 0
// eslint-disable-next-line
while (true) {
try {
// Acceptable blank because API_KEY is not required
await pull('v1/apikey', process.env.API_KEY ?? '')
// Acceptable blank because ROOT_API_KEY is not required
await pull('v1/apikey', context.oidc?.rootKey ?? '')
return
} catch (error) {
// This means the server is up but the API key is invalid
// This can happen if the user only uses API_KEY via cookies
// This can happen if the user only uses ROOT_API_KEY via cookies
if (error instanceof HeadscaleError && error.status === 401) {
break
}
+10 -10
View File
@@ -1,4 +1,4 @@
import { getContext } from './config'
import { loadContext } from './config/headplane'
export class HeadscaleError extends Error {
status: number
@@ -18,12 +18,12 @@ export class FatalError extends Error {
}
export async function pull<T>(url: string, key: string) {
const context = await getContext()
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`
}
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
@@ -34,14 +34,14 @@ export async function pull<T>(url: string, key: string) {
}
export async function post<T>(url: string, key: string, body?: unknown) {
const context = await getContext()
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`
}
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
@@ -52,13 +52,13 @@ export async function post<T>(url: string, key: string, body?: unknown) {
}
export async function del<T>(url: string, key: string) {
const context = await getContext()
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`
}
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
+65 -40
View File
@@ -1,36 +1,42 @@
import { redirect } from '@remix-run/node'
import {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge, type Client,
calculatePKCECodeChallenge,
type Client,
discoveryRequest,
generateRandomCodeVerifier,
generateRandomNonce,
generateRandomState,
getValidatedIdTokenClaims, isOAuth2Error,
getValidatedIdTokenClaims,
isOAuth2Error,
parseWwwAuthenticateChallenges,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
validateAuthResponse } from 'oauth4webapi'
validateAuthResponse,
} from 'oauth4webapi'
import { post } from '~/utils/headscale'
import { commitSession, getSession } from '~/utils/sessions'
export async function startOidc(issuer: string, client: string, request: Request) {
const session = await getSession(request.headers.get('Cookie'))
import { HeadplaneContext } from './config/headplane'
type OidcConfig = NonNullable<HeadplaneContext['oidc']>
export async function startOidc(oidc: OidcConfig, req: Request) {
const session = await getSession(req.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
'Set-Cookie': await commitSession(session),
},
})
}
const issuerUrl = new URL(issuer)
const issuerUrl = new URL(oidc.issuer)
const oidcClient = {
client_id: client,
token_endpoint_auth_method: 'client_secret_basic'
client_id: oidc.client,
token_endpoint_auth_method: 'client_secret_basic',
} satisfies Client
const response = await discoveryRequest(issuerUrl)
@@ -44,9 +50,9 @@ export async function startOidc(issuer: string, client: string, request: Request
const verifier = generateRandomCodeVerifier()
const challenge = await calculatePKCECodeChallenge(verifier)
const callback = new URL('/admin/oidc/callback', request.url)
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = request.headers.get('Host') ?? ''
const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = req.headers.get('Host') ?? ''
const authUrl = new URL(processed.authorization_endpoint)
authUrl.searchParams.set('client_id', oidcClient.client_id)
@@ -65,29 +71,27 @@ export async function startOidc(issuer: string, client: string, request: Request
return redirect(authUrl.href, {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
'Set-Cookie': await commitSession(session),
},
})
}
export async function finishOidc(issuer: string, client: string, secret: string, request: Request) {
const session = await getSession(request.headers.get('Cookie'))
export async function finishOidc(oidc: OidcConfig, req: Request) {
const session = await getSession(req.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
'Set-Cookie': await commitSession(session),
},
})
}
const issuerUrl = new URL(issuer)
const issuerUrl = new URL(oidc.issuer)
const oidcClient = {
client_id: client,
client_secret: secret,
token_endpoint_auth_method: 'client_secret_basic'
client_id: oidc.client,
client_secret: oidc.secret,
token_endpoint_auth_method: 'client_secret_basic',
} satisfies Client
const response = await discoveryRequest(issuerUrl)
@@ -103,22 +107,41 @@ export async function finishOidc(issuer: string, client: string, secret: string,
throw new Error('No OIDC state found in the session')
}
const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state)
const parameters = validateAuthResponse(
processed,
oidcClient,
new URL(req.url),
state,
)
if (isOAuth2Error(parameters)) {
throw new Error('Invalid response from the OIDC provider')
}
const callback = new URL('/admin/oidc/callback', request.url)
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = request.headers.get('Host') ?? ''
const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = req.headers.get('Host') ?? ''
const tokenResponse = await authorizationCodeGrantRequest(
processed,
oidcClient,
parameters,
callback.href,
verifier,
)
const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier)
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
if (challenges) {
throw new Error('Recieved a challenge from the OIDC provider')
}
const result = await processAuthorizationCodeOpenIDResponse(processed, oidcClient, tokenResponse, nonce)
const result = await processAuthorizationCodeOpenIDResponse(
processed,
oidcClient,
tokenResponse,
nonce,
)
if (isOAuth2Error(result)) {
throw new Error('Invalid response from the OIDC provider')
}
@@ -126,21 +149,23 @@ export async function finishOidc(issuer: string, client: string, secret: string,
const claims = getValidatedIdTokenClaims(result)
const expDate = new Date(claims.exp * 1000).toISOString()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const keyResponse = await post<{ apiKey: string }>('v1/apikey', process.env.API_KEY!, {
expiration: expDate
})
const keyResponse = await post<{ apiKey: string }>(
'v1/apikey',
oidc.rootKey,
{
expiration: expDate,
},
)
session.set('hsApiKey', keyResponse.apiKey)
session.set('user', {
name: claims.name ? String(claims.name) : 'Anonymous',
email: claims.email ? String(claims.email) : undefined
email: claims.email ? String(claims.email) : undefined,
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
'Set-Cookie': await commitSession(session),
},
})
}
+6 -2
View File
@@ -30,10 +30,14 @@ The ACL file path is read from the following sources in order of priority:
The Docker integration can be used to automatically reload the configuration or ACLs when they are changed.
In order for this to work, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
You'll also need to ensure that `/var/run/docker.sock` is mounted if Headplane is running in a container.
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
By default the integration will check for `/var/run/docker.sock`, however you can override this by
setting the `DOCKER_SOCK` environment variable if you use a different configuration than the default.
When setting `DOCKER_SOCK`, you'll need to include the protocol (e.g., `unix://` or `tcp://`).
Headplane currently does not support the HTTPS protocol for the Docker socket.
## Deployment
Requirements:
@@ -85,7 +89,7 @@ services:
# This NEEDS to be set with OIDC, regardless of what's in the config
# This needs to be a very long-lived (999 day) API key used to create
# shorter ones for OIDC and allow the OIDC functionality to work
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
```
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
+1 -2
View File
@@ -31,8 +31,7 @@ services:
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
# These are all optional!
HEADSCALE_CONTAINER: 'headscale'
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
OIDC_CLIENT_ID: 'headscale'
OIDC_ISSUER: 'https://sso.example.com'
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
+9 -1
View File
@@ -13,6 +13,14 @@ You can configure Headplane using environment variables.
- **`PORT`**: The port to bind the server to (default: `3000`).
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
#### Docker Integration
The Docker integration allows Headplane to manage the Headscale docker container.
You'll need to provide these variables if you want to use this feature.
Keep in mind that `DOCKER_SOCK` must start with a protocol (e.g., `unix://`).
Secure API is currently not supported.
- **`DOCKER_SOCK`**: The protocol and path to the Docker socket (default: `unix:///var/run/docker.sock`).
- **`HEADSCALE_CONTAINER`**: The name of the Headscale container (required for Docker integration).
### SSO/OpenID Connect
@@ -23,7 +31,7 @@ If you use the Headscale configuration integration, these are not required.
- **`OIDC_ISSUER`**: The issuer URL of your OIDC provider.
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
- **`API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
Here's what an example with Authelia would look like if you used the same client for both Headscale and Headplane.
+4 -2
View File
@@ -37,7 +37,8 @@
"tailwindcss-react-aria-components": "^1.1.2",
"undici": "^6.16.1",
"usehooks-ts": "^3.1.0",
"yaml": "^2.4.2"
"yaml": "^2.4.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.9.2",
@@ -60,7 +61,8 @@
"@types/react-dom": "npm:types-react-dom@beta"
},
"engines": {
"node": ">=18.0.0"
"node": ">=20",
"pnpm": ">=9 <10"
},
"pnpm": {
"patchedDependencies": {
+5475 -4282
View File
File diff suppressed because it is too large Load Diff