Compare commits

...

15 Commits

Author SHA1 Message Date
Aarnav Tale f0f8a6f01b chore: v0.1.8 2024-06-02 01:40:05 -04:00
Aarnav Tale 3bd5cc99aa fix: handle 0001-01-01 00:00:00 as the no-expiry timestamp 2024-06-02 01:37:31 -04:00
Aarnav Tale 937fa6f6fa fix: use proper route ids for key mapping 2024-06-02 01:34:58 -04:00
Aarnav Tale 3ffbabd7fc feat: add functional machine overview page and fix types 2024-06-02 01:33:40 -04:00
Aarnav Tale c08203cc76 fix: prevent user deletion if they have machines owned 2024-06-02 00:54:20 -04:00
Aarnav Tale 57f045eaf6 fix: support top level await on the server 2024-05-30 11:41:36 -04:00
Aarnav Tale faa583bb38 chore: v0.1.7 2024-05-30 10:41:55 -04:00
Aarnav Tale 98ea2cb06f feat: add a way to escape hatch from strict config checking 2024-05-30 10:41:40 -04:00
Aarnav Tale 868d85bbeb chore: log about context loading 2024-05-30 10:28:37 -04:00
Aarnav Tale dbd9d39da9 style: cleanup vite.config.ts 2024-05-30 10:20:53 -04:00
Aarnav Tale 72cc9d8974 fix: flexible duration and go boolean handling 2024-05-30 10:20:41 -04:00
Aarnav Tale ed50c48965 fix: handle configs better and propagate errors 2024-05-30 10:15:02 -04:00
Aarnav Tale e19dbda5ed fix: headscale expiry can be a literal 0 2024-05-26 19:41:56 -04:00
Aarnav Tale a72cc1bb1c chore: document the new integration providers 2024-05-26 19:40:46 -04:00
Aarnav Tale f0e4868252 feat: configure support for generic integrations
Co-authored-by: Gage Orsburn <gageorsburn@live.com>
2024-05-26 19:23:24 -04:00
29 changed files with 991 additions and 501 deletions
+15 -1
View File
@@ -1,4 +1,18 @@
### 0.1.5 (May 22, 2024)
### 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines.
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
### 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
- Headplane's context now only loads once at start instead of being lazy-loaded.
- Improved logging and error propagation so that it's easier to debug issues.
### 0.1.6 (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.
+11 -3
View File
@@ -13,9 +13,17 @@ It's able to replicate nearly all of the functions of the official Tailscale Saa
- DNS and *safe* Headscale configuration management
## Deployment
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
*If you aren't sure which one to pick, we recommend this.*
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
instead requiring manual editing and reloading when making changes.
## Contributing
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
+5 -1
View File
@@ -9,7 +9,11 @@ import Menu from './Menu'
import TabLink from './TabLink'
interface Properties {
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
readonly data?: {
acl: HeadplaneContext['acl']
config: HeadplaneContext['config']
user?: SessionData['user']
}
}
interface LinkProperties {
+10 -11
View File
@@ -2,30 +2,29 @@ import { LinkExternalIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
/* eslint-disable unicorn/no-keyword-prefix */
type Properties = {
readonly to: string;
readonly name: string;
readonly children: string;
readonly className?: string;
interface Props {
to: string
name: string
children: string
className?: string
}
export default function Link({ to, name: alt, children, className }: Properties) {
export default function Link({ to, name: alt, children, className }: Props) {
return (
<a
href={to}
aria-label={alt}
target='_blank'
rel='noreferrer'
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex items-center gap-x-1',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
className
className,
)}
>
{children}
<LinkExternalIcon className='h-3 w-3'/>
<LinkExternalIcon className="h-3 w-3" />
</a>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { PassThrough } from 'node:stream'
import type { AppLoadContext, 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'
import { loadContext } from './utils/config/headplane'
await loadContext()
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_loadContext: AppLoadContext,
) {
const ua = request.headers.get('user-agent')
const isBot = ua ? isbot(ua) : false
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : '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 as Error)
},
onError(error: unknown) {
responseStatusCode = 500
if (shellRendered) {
console.error(error)
}
},
},
)
setTimeout(abort, 5000)
})
}
+127
View File
@@ -0,0 +1,127 @@
import { access, constants } from 'node:fs/promises'
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { HeadscaleError, pull } from '~/utils/headscale'
import type { Integration } from '.'
// Integration name
const name = 'Docker'
let url: URL | undefined
let container: string | undefined
async function preflight() {
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
try {
url = new URL(path)
} catch {
return false
}
// 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 false
}
}
if (url.protocol === 'http:') {
try {
await fetch(new URL('/v1.30/version', url).href)
} catch {
return false
}
}
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
return false
}
container = process.env.HEADSCALE_CONTAINER
?.trim()
.toLowerCase()
if (!container || container.length === 0) {
return false
}
return true
}
async function sighup() {
if (!url || !container) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = url.protocol === 'unix:'
? new Client('http://localhost', {
socketPath: url.href,
})
: new Client(url.href)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to send SIGHUP to Headscale')
}
}
async function restart() {
if (!url || !container) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = url.protocol === 'unix:'
? new Client('http://localhost', {
socketPath: url.href,
})
: new Client(url.href)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/restart`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to restart Headscale')
}
// Wait for Headscale to restart before continuing
let attempts = 0
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
while (true) {
try {
await pull('v1', '')
return
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}
export default { name, preflight, sighup, restart } satisfies Integration
+58
View File
@@ -0,0 +1,58 @@
import docker from './docker'
import proc from './proc'
export interface Integration {
name: string
preflight: () => Promise<boolean>
sighup?: () => Promise<void>
restart?: () => Promise<void>
}
// Because we previously supported the Docker integration by
// checking for the HEADSCALE_CONTAINER variable, we need to
// check for it here as well.
//
// This ensures that when people upgrade from older versions
// of Headplane, they don't explicitly need to define the new
// HEADSCALE_INTEGRATION variable that is needed to configure
// an integration.
export async function checkIntegration() {
let integration = process.env.HEADSCALE_INTEGRATION
?.trim()
.toLowerCase()
// Old HEADSCALE_CONTAINER variable upgrade path
if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker'
}
if (!integration) {
console.log('Running Headplane without any integrations')
return
}
let module: Integration | undefined
try {
module = getIntegration(integration)
await module.preflight()
} catch (error) {
console.error('Failed to load integration', error)
return
}
return module
}
function getIntegration(name: string) {
switch (name) {
case 'docker': {
return docker
}
case 'proc': {
return proc
}
default: {
throw new Error(`Unknown integration: ${name}`)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import { access, constants, readdir, readFile } from 'node:fs/promises'
import { platform } from 'node:os'
import { join, resolve } from 'node:path'
import { kill } from 'node:process'
import type { Integration } from '.'
// Integration name
const name = 'Native Linux (/proc)'
// Check if we have a /proc and if it's readable
async function preflight() {
if (platform() !== 'linux') {
return false
}
const dir = resolve('/proc')
try {
await access(dir, constants.R_OK)
return true
} catch (error) {
console.error('Failed to access /proc', error)
return false
}
}
async function findPid() {
const dirs = await readdir('/proc')
const promises = dirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10)
if (Number.isNaN(pid)) {
return
}
const path = join('/proc', dir, 'cmdline')
try {
const data = await readFile(path, 'utf8')
if (data.includes('headscale')) {
return pid
}
} catch {}
})
const results = await Promise.allSettled(promises)
const pids = []
for (const result of results) {
if (result.status === 'fulfilled') {
pids.push(result.value)
}
}
if (pids.length > 1) {
console.warn('Found multiple Headscale processes', pids)
console.log('Disabling the /proc integration')
return
}
if (pids.length === 0) {
console.warn('Could not find Headscale process')
console.log('Disabling the /proc integration')
return
}
return pids[0]
}
async function sighup() {
const pid = await findPid()
if (!pid) {
return
}
try {
kill(pid, 'SIGHUP')
} catch (error) {
console.error('Failed to send SIGHUP to Headscale', error)
}
}
export default { name, preflight, sighup } satisfies Integration
+2 -3
View File
@@ -9,7 +9,6 @@ import Link from '~/components/Link'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import Editor from './editor'
@@ -47,8 +46,8 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.docker) {
await sighupHeadscale()
if (context.integration?.sighup) {
await context.integration.sighup()
}
return json({ success: true })
+5 -2
View File
@@ -5,7 +5,6 @@ import Code from '~/components/Code'
import Notice from '~/components/Notice'
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'
@@ -56,7 +55,11 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
await restartHeadscale()
if (context.integration?.restart) {
await context.integration.restart()
}
return json({ success: true })
}
+145 -31
View File
@@ -4,72 +4,186 @@ import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { type Machine, Route } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import MenuOptions from './_data.machines._index/menu'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!params.id) {
throw new Error('No machine ID provided')
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
return data.node
const context = await loadContext()
let magic: string | undefined
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
}
const [machine, routes] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
])
return {
machine: machine.node,
routes: routes.routes.filter(route => route.node.id === params.id),
magic,
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const { machine, magic, routes } = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
const expired = machine.expiry === '0001-01-01 00:00:00'
? false
: new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<p className="mb-8 text-md">
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
to="/machines"
className="font-medium"
>
All Machines
</Link>
{' / '}
{data.givenName}
<span className="mx-2">
/
</span>
{machine.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card variant='flat'>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<div className="flex justify-between items-center">
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">
{machine.givenName}
</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
machine={machine}
routes={routes}
magic={magic}
/>
</div>
<div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => (
<span
key={tag}
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',
)}
>
{tag}
</span>
))}
</div>
<h2 className="text-xl font-medium mb-4">
Machine Details
</h2>
<Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} />
<Attribute
isCopyable
name='Node Key'
value={data.nodeKey}
name="Node Key"
value={machine.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
name="Expiry"
value={new Date(machine.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
name="Domain"
value={`${machine.givenName}.${machine.user.name}.ts.net`}
/>
</Card>
<h2 className="text-xl font-medium mb-4 mt-8">
Machine Routes
</h2>
<Card variant="flat" className="w-full max-w-full">
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: routes.map((route, i) => (
<div
key={route.id}
className={cn(
'flex items-center justify-between',
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
'border-ui-100 dark:border-ui-800',
)}
>
<div>
<p className="font-mono mb-1">
{route.prefix}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{' '}
(Created:
{' '}
{new Date(route.createdAt).toLocaleString()}
)
</p>
</div>
<div className="text-right">
<p className="mb-1">
{route.enabled ? 'Enabled' : 'Disabled'}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{route.isPrimary ? 'Primary' : 'Secondary'}
</p>
</div>
</div>
))}
</Card>
</div>
)
}
@@ -1,54 +1,62 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface DeleteProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
export default function Delete({ machine, fetcher, state }: DeleteProperties) {
export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Remove {machine.givenName}
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from
your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='delete'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant='cancel'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
'text-white dark:text-white',
)}
onPress={close}
>
Remove
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>
@@ -1,54 +1,62 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface ExpireProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Expire {machine.givenName}
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate
from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='expire'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant='cancel'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
'text-white dark:text-white',
)}
onPress={close}
>
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>
@@ -1,4 +1,4 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
@@ -6,15 +6,15 @@ import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { type Machine } from '~/types'
type RenameProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
interface RenameProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
}
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName)
const submit = useSubmit()
return (
<Dialog>
@@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
{close => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
Edit machine name for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='rename'/>
<input type='hidden' name='id' value={machine.id}/>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField
label='Machine name'
placeholder='Machine name'
name='name'
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className='my-2'
className="my-2"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
This machine will be accessible by the hostname
{' '}
<Code className='text-sm'>
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className='text-sm'>
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
) : (
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
This machine is accessible by the hostname
{' '}
<Code className='text-sm'>
{machine.givenName}
</Code>
.
</p>
)
) : undefined}
<div className='mt-6 flex justify-end gap-2 mt-6'>
{magic
? (
name.length > 0 && name !== machine.givenName
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname
{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
)
: (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
</Code>
.
</p>
)
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant='cancel'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
onPress={close}
>
Rename
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>
@@ -1,4 +1,4 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { useFetcher } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
@@ -6,22 +6,25 @@ import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
type RoutesProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface RoutesProps {
readonly machine: Machine
readonly routes: Route[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
export default function Routes({ machine, routes, state }: RoutesProps) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
Edit route settings of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
@@ -30,28 +33,30 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700'
'border border-zinc-200 dark:border-zinc-700',
)}
>
{routes.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300'
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
) : undefined}
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: undefined}
{routes.map(route => (
<div
key={route.node.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between'
'items-center justify-between',
)}
>
<p>
@@ -59,8 +64,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
</p>
<Switch
defaultSelected={route.enabled}
label='Enabled'
onChange={checked => {
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
@@ -68,16 +73,16 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST'
method: 'POST',
})
}}
/>
</div>
))}
</div>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant='cancel'
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
+57 -112
View File
@@ -1,7 +1,5 @@
/* eslint-disable react/hook-use-state */
import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react'
import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react'
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
import { Link } from '@remix-run/react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
@@ -9,28 +7,22 @@ import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
import MenuOptions from './menu'
type MachineProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly magic?: string;
interface Props {
readonly machine: Machine
readonly routes: Route[]
readonly magic?: string
}
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
export default function MachineRow({ machine, routes, magic }: Props) {
const expired = machine.expiry === '0001-01-01 00:00:00'
? false
: new Date(machine.expiry).getTime() < Date.now()
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags
...machine.validTags,
]
if (expired) {
@@ -40,32 +32,32 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
return (
<tr
key={machine.id}
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
>
<td className='pl-0.5 py-2'>
<td className="pl-0.5 py-2">
<Link
to={`/machines/${machine.id}`}
className='group/link h-full'
className="group/link h-full"
>
<p className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400'
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'>
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name}
</p>
<div className='flex gap-1 mt-1'>
<div className="flex gap-1 mt-1">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-sm px-1 py-0.5',
'bg-gray-100 dark:bg-zinc-700',
'text-gray-600 dark:text-gray-300'
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
@@ -74,21 +66,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
</div>
</Link>
</td>
<td className='py-2'>
<div className='flex items-center gap-x-1'>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className='w-4 h-4'/>
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
<Menu.ItemButton
key={ip}
type='button'
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full'
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
@@ -96,108 +88,61 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
}}
>
{ip}
<CopyIcon className='w-3 h-3'/>
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic ? (
<Menu.ItemButton
type='button'
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep'
)}
onPress={async () => {
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}.{machine.user.name}.{magic}
<CopyIcon className='w-3 h-3'/>
</Menu.ItemButton>
) : undefined}
{magic
? (
<Menu.ItemButton
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}
.
{machine.user.name}
.
{magic}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
: undefined}
</Menu.Items>
</Menu>
</div>
</td>
<td className='py-2'>
<td className="py-2">
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400'
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className='w-4 h-4'
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen
machine.lastSeen,
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-0.5'>
<Rename
machine={machine}
fetcher={fetcher}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
fetcher={fetcher}
state={removeState}
/>
{expired ? undefined : (
<Expire
machine={machine}
fetcher={fetcher}
state={expireState}
/>
)}
<Routes
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
fetcher={fetcher}
state={routesState}
magic={magic}
/>
<Menu>
<Menu.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<KebabHorizontalIcon className='w-5'/>
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={renameState}>
Edit machine name
</Menu.ItemButton>
<Menu.ItemButton control={routesState}>
Edit route settings
</Menu.ItemButton>
<Menu.Item className='opacity-50 hover:bg-transparent'>
Edit ACL tags
</Menu.Item>
{expired ? undefined : (
<Menu.ItemButton control={expireState}>
Expire
</Menu.ItemButton>
)}
<Menu.ItemButton
className='text-red-500 dark:text-red-400'
control={removeState}
>
Remove
</Menu.ItemButton>
</Menu.Items>
</Menu>
</td>
</tr>
)
+91
View File
@@ -0,0 +1,91 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'
import { useState } from 'react'
import MenuComponent from '~/components/Menu'
import { Machine, Route } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
interface MenuProps {
machine: Machine
routes: Route[]
magic?: string
}
export default function Menu({ machine, routes, magic }: MenuProps) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const expired = machine.expiry === '0001-01-01 00:00:00'
? false
: new Date(machine.expiry).getTime() < Date.now()
return (
<>
<Rename
machine={machine}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
state={removeState}
/>
{expired
? undefined
: (
<Expire
machine={machine}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<MenuComponent>
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
)}
>
<KebabHorizontalIcon className="w-5" />
</MenuComponent.Button>
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
Edit machine name
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.Item className="opacity-50 hover:bg-transparent">
Edit ACL tags
</MenuComponent.Item>
{expired
? undefined
: (
<MenuComponent.ItemButton control={expireState}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
>
Remove
</MenuComponent.ItemButton>
</MenuComponent.Items>
</MenuComponent>
</>
)
}
+3 -6
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InfoIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
@@ -107,7 +107,6 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
return (
<>
@@ -155,10 +154,8 @@ export default function Page() {
{data.nodes.map(machine => (
<MachineRow
key={machine.id}
// Typescript isn't smart enough yet
machine={machine as unknown as Machine}
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
fetcher={fetcher}
machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)}
magic={data.magic}
/>
))}
+2 -1
View File
@@ -35,7 +35,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
return {
...context,
acl: context.acl,
config: context.config,
user: session.get('user'),
}
}
+7 -13
View File
@@ -182,15 +182,12 @@ export default function Page() {
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
// @ts-expect-error: JsonifyObject is weird
<Users users={users} />
}
>
{() => (
<InteractiveUsers
// @ts-expect-error: JsonifyObject is weird
users={users}
// @ts-expect-error: JsonifyObject is weird
setUsers={setUsers}
magic={data.magic}
/>
@@ -211,11 +208,10 @@ interface UserProps {
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
{users.map(user => (
<UserCard
key={user.id}
user={user}
isFirst={i === 0}
magic={magic}
/>
))}
@@ -267,11 +263,10 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
{users.map(user => (
<UserCard
key={user.id}
user={user}
isFirst={i === 0}
magic={magic}
/>
))}
@@ -312,11 +307,10 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
interface CardProps {
user: UserMachine
isFirst: boolean
magic?: string
}
function UserCard({ user, isFirst, magic }: CardProps) {
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
})
@@ -339,11 +333,11 @@ function UserCard({ user, isFirst, magic }: CardProps) {
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
{isFirst
? undefined
: (
{user.machines.length === 0
? (
<Remove username={user.name} magic={magic} />
)}
)
: undefined}
</div>
</div>
<div className="mt-4">
+18 -18
View File
@@ -1,28 +1,28 @@
import type { User } from './User'
export type Machine = {
id: string;
machineKey: string;
nodeKey: string;
discoKey: string;
ipAddresses: string[];
name: string;
export interface Machine {
id: string
machineKey: string
nodeKey: string
discoKey: string
ipAddresses: string[]
name: string
user: User;
lastSeen: Date;
expiry: Date;
user: User
lastSeen: string
expiry: string
preAuthKey?: unknown; // TODO
preAuthKey?: unknown // TODO
createdAt: Date;
createdAt: string
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
| 'REGISTER_METHOD_AUTH_KEY'
| 'REGISTER_METHOD_CLI'
| 'REGISTER_METHOD_OIDC';
| 'REGISTER_METHOD_OIDC'
forcedTags: string[];
invalidTags: string[];
validTags: string[];
givenName: string;
online: boolean;
forcedTags: string[]
invalidTags: string[]
validTags: string[]
givenName: string
online: boolean
}
+10 -10
View File
@@ -1,13 +1,13 @@
import type { Machine } from './Machine'
export type Route = {
id: string;
node: Machine;
prefix: string;
advertised: boolean;
enabled: boolean;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
export interface Route {
id: string
node: Machine
prefix: string
advertised: boolean
enabled: boolean
isPrimary: boolean
createdAt: string
updatedAt: string
deletedAt: string
}
+4 -4
View File
@@ -1,5 +1,5 @@
export type User = {
id: string;
name: string;
createdAt: Date;
export interface User {
id: string
name: string
createdAt: string
}
+32 -63
View File
@@ -8,11 +8,14 @@ import { resolve } from 'node:path'
import { parse } from 'yaml'
import { checkIntegration, Integration } from '~/integration'
import { HeadscaleConfig, loadConfig } from './headscale'
export interface HeadplaneContext {
headscaleUrl: string
cookieSecret: string
integration: Integration | undefined
config: {
read: boolean
@@ -24,12 +27,6 @@ export interface HeadplaneContext {
write: boolean
}
docker?: {
url: string
sock: boolean
container: string
}
oidc?: {
issuer: string
client: string
@@ -46,12 +43,8 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context
}
let config: HeadscaleConfig | undefined
try {
config = await loadConfig()
} catch {}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const { config, contextData } = await checkConfig(path)
let headscaleUrl = process.env.HEADSCALE_URL
if (!headscaleUrl && !config) {
@@ -74,12 +67,19 @@ export async function loadContext(): Promise<HeadplaneContext> {
context = {
headscaleUrl,
cookieSecret,
config: await checkConfig(path, config),
integration: await checkIntegration(),
config: contextData,
acl: await checkAcl(config),
docker: await checkDocker(),
oidc: await checkOidc(config),
}
console.log('Completed loading the Headplane Context')
console.log('Headscale URL:', headscaleUrl)
console.log('Integration:', context.integration?.name ?? 'None')
console.log('Config:', contextData.read ? `Found ${contextData.write ? '' : '(Read Only)'}` : 'Unavailable')
console.log('ACL:', context.acl.read ? `Found ${context.acl.write ? '' : '(Read Only)'}` : 'Unavailable')
console.log('OIDC:', context.oidc ? 'Configured' : 'Unavailable')
return context
}
@@ -124,7 +124,20 @@ export async function patchAcl(data: string) {
await writeFile(path, data, 'utf8')
}
async function checkConfig(path: string, config?: HeadscaleConfig) {
async function checkConfig(path: string) {
let config: HeadscaleConfig | undefined
try {
config = await loadConfig(path)
} catch {
return {
config: undefined,
contextData: {
read: false,
write: false,
},
}
}
let write = false
try {
await access(path, constants.W_OK)
@@ -132,8 +145,11 @@ async function checkConfig(path: string, config?: HeadscaleConfig) {
} catch {}
return {
read: config ? true : false,
write,
config,
contextData: {
read: true,
write,
},
}
}
@@ -163,53 +179,6 @@ async function checkAcl(config?: HeadscaleConfig) {
}
}
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
+79 -28
View File
@@ -12,6 +12,18 @@ import { resolve } from 'node:path'
import { type Document, parseDocument } from 'yaml'
import { z } from 'zod'
const goBool = z
.union([z.boolean(), z.literal('true'), z.literal('false')])
.transform((value) => {
if (typeof value === 'boolean') {
return value
}
return value === 'true'
})
const goDuration = z.union([z.literal(0), z.string()])
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'),
@@ -26,11 +38,11 @@ const HeadscaleConfig = z.object({
listen_addr: z.string(),
metrics_listen_addr: z.string().optional(),
grpc_listen_addr: z.string().default(':50443'),
grpc_allow_insecure: z.boolean().default(false),
grpc_allow_insecure: goBool.default(false),
disable_check_updates: z.boolean().default(false),
ephemeral_node_inactivity_timeout: z.string().default('120s'),
randomize_client_port: z.boolean().default(false),
disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
randomize_client_port: goBool.default(false),
acl_policy_path: z.string().optional(),
acme_email: z.string().optional(),
@@ -40,7 +52,7 @@ const HeadscaleConfig = z.object({
unix_socket_permission: z.string().default('0o770'),
tuning: z.object({
batch_change_delay: z.string().default('800ms'),
batch_change_delay: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30),
}).optional(),
@@ -54,14 +66,14 @@ const HeadscaleConfig = z.object({
}).default({ level: 'info', format: 'text' }),
logtail: z.object({
enabled: z.boolean().default(false),
enabled: goBool.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),
timeout: goDuration.default('10s'),
insecure: goBool.default(false),
}).optional(),
prefixes: z.object({
@@ -71,7 +83,7 @@ const HeadscaleConfig = z.object({
}),
dns_config: z.object({
override_local_dns: z.boolean().default(true),
override_local_dns: goBool.default(false),
nameservers: z.array(z.string()).default([]),
restricted_nameservers: z.record(z.array(z.string())).default({}),
domains: z.array(z.string()).default([]),
@@ -80,51 +92,51 @@ const HeadscaleConfig = z.object({
type: z.literal('A'),
value: z.string(),
})).default([]),
magic_dns: z.boolean().default(false),
magic_dns: goBool.default(false),
base_domain: z.string().default('headscale.net'),
}),
oidc: z.object({
only_start_if_oidc_is_available: z.boolean().default(true),
only_start_if_oidc_is_available: goBool.default(false),
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({}),
extra_params: z.record(z.unknown()).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),
strip_email_domain: goBool.default(false),
expiry: goDuration.default('180d'),
use_expiry_from_token: goBool.default(false),
}).optional(),
database: z.union([
z.object({
type: z.literal('sqlite'),
debug: z.boolean().default(false),
debug: goBool.default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('sqlite3'),
debug: z.boolean().default(false),
debug: goBool.default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('postgres'),
debug: z.boolean().default(false),
debug: goBool.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),
ssl: goBool.default(true),
max_open_conns: z.number().default(10),
max_idle_conns: z.number().default(10),
conn_max_idle_time_secs: z.number().default(3600),
@@ -134,7 +146,7 @@ const HeadscaleConfig = z.object({
derp: z.object({
server: z.object({
enabled: z.boolean().default(false),
enabled: goBool.default(true),
region_id: z.number().optional(),
region_code: z.string().optional(),
region_name: z.string().optional(),
@@ -143,13 +155,13 @@ const HeadscaleConfig = z.object({
ipv4: z.string().optional(),
ipv6: z.string().optional(),
automatically_add_embedded_derp_region: z.boolean().default(true),
automatically_add_embedded_derp_region: goBool.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'),
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default('24h'),
}),
})
@@ -158,16 +170,52 @@ export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
export let configYaml: Document | undefined
export let config: HeadscaleConfig | undefined
export async function loadConfig() {
export async function loadConfig(path?: string) {
if (config) {
return config
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const data = await readFile(path, 'utf8')
if (!path) {
throw new Error('Path is required to lazy load config')
}
const data = await readFile(path, 'utf8')
configYaml = parseDocument(data)
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
config = configYaml.toJSON() as HeadscaleConfig
console.log('Loaded Headscale configuration in non-strict mode')
console.log('By using this mode you forfeit GitHub issue support')
console.log('This is very dangerous and comes with a few caveats:')
console.log('- Headplane could very easily crash')
console.log('- Headplane could break your Headscale installation')
console.log('- The UI could throw random errors/show incorrect data')
console.log('')
return config
}
try {
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
} catch (error) {
if (error instanceof z.ZodError) {
console.log('Failed to parse the Headscale configuration file!')
console.log('The following schema issues were found:')
for (const issue of error.issues) {
const path = issue.path.map(String).join('.')
const message = issue.message
console.log(`- '${path}': ${message}`)
}
console.log('')
console.log('Please fix the configuration file and try again.')
console.log('Headplane will operate as if no config is present.')
console.log('')
}
throw error
}
return config
}
@@ -209,7 +257,10 @@ export async function patchConfig(partial: Record<string, unknown>) {
configYaml.setIn(path, value)
}
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
config = process.env.HEADSCALE_CONFIG_UNSTRICT === 'true'
? configYaml.toJSON() as HeadscaleConfig
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
await writeFile(path, configYaml.toString(), 'utf8')
}
-76
View File
@@ -1,76 +0,0 @@
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { loadContext } from './config/headplane'
import { HeadscaleError, pull } from './headscale'
export async function sighupHeadscale() {
const context = await loadContext()
if (!context.docker) {
return
}
// 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 response = await client.request({
method: 'POST',
path: `/v1.30/containers/${context.docker.container}/kill?signal=SIGHUP`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to send SIGHUP to Headscale')
}
}
export async function restartHeadscale() {
const context = await loadContext()
if (!context.docker) {
return
}
// 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 response = await client.request({
method: 'POST',
path: `/v1.30/containers/${context.docker.container}/restart`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to restart Headscale')
}
// Wait for Headscale to restart before continuing
let attempts = 0
// eslint-disable-next-line
while (true) {
try {
// 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 ROOT_API_KEY via cookies
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}
+22 -7
View File
@@ -4,9 +4,9 @@
With the advanced integration it's possible to control Access Control Lists (ACLs) and the Headscale configuration via the Headplane UI.
Every single aspect of this integration is optional, meaning you can only use what you want.
If you want to use this integration, you do not need Docker and you can make it work with Headscale and Headplane running natively.
Additionally, with an integration provider, you can automatically reload the configuration or ACLs when they are changed.
### Configuration Editing
## Configuration Editing
When the configuration file is available to Headplane, the `DNS` and `Settings` tabs will become functional.
Similar to the Tailscale UI, you'll be able to edit the configuration without needing to manually edit the file.
@@ -16,7 +16,7 @@ By default this is set to `/etc/headscale/config.yaml`.
> One important think to note is that environment variables always take priority over the configuration file.
> The `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDFC_ISSUER`, and `OIDC_CLIENT_SECRET` will be preferred over the configuration file if available.
### Access Control Lists (ACLs)
## Access Control Lists (ACLs)
![ACL Preview](/assets/acl-preview.png)
@@ -26,10 +26,16 @@ The ACL file path is read from the following sources in order of priority:
- **Environment Variable**: If you set the `ACL_FILE` environment variable, Headplane will read the file from that path.
- **Configuration Integration**: If you've set this up, then Headplane will read the `acl_policy_path` key from the configuration file.
## Automatic Configuration Reload
When the configuration file is changed, Headscale will need to be restarted to apply the changes.
Similarly, when the ACL file is changed, Headscale will need to be sent a `SIGHUP` signal to reload the ACLs.
Currently there are 2 integration providers that can do this for you:
### Docker Integration
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.
To enable the Docker integration, set `HEADSCALE_INTEGRATION=docker` in the environment variables.
Additionally, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
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.
@@ -38,7 +44,7 @@ setting the `DOCKER_SOCK` environment variable if you use a different configurat
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
#### Example Docker Deployment
Requirements:
- Headscale 0.23 alpha or later
@@ -75,6 +81,7 @@ services:
# This is always required for Headplane to work
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
HEADSCALE_INTEGRATION: 'docker'
HEADSCALE_CONTAINER: 'headscale'
DISABLE_API_KEY_LOGIN: 'true'
HOST: '0.0.0.0'
@@ -98,9 +105,17 @@ services:
You may also choose to run it natively with the distributed binaries on the releases page.
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
### Native Linux Integration (Beta)
The native integration for Linux relies on the `/proc` directory to locate the Headscale process.
To enable it, set the `HEADSCALE_INTEGRATION=proc` value in the environment variables.
Because of the way this integration works, it only supports automatically reloading ACLs.
It's still very experimental and may not work in all environments.
## Configuration Scenarios
Since the configuration is fairly modular you can have a variety of different setups.<br>
Since the configuration is fairly modular you can have a variety of different setups.
This mostly applies to the Docker integration since the native integration isn't fully featured yet.
Here are a few examples to inspire you and show you what can work and what can't:
#### Full Integration
+1
View File
@@ -13,6 +13,7 @@ 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).
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
#### Docker Integration
The Docker integration allows Headplane to manage the Headscale docker container.
+5 -6
View File
@@ -6,10 +6,9 @@ import tsconfigPaths from 'vite-tsconfig-paths'
installGlobals()
const ReactCompilerConfig = {}
export default defineConfig({
export default defineConfig(({ isSsrBuild }) => ({
base: '/admin/',
build: isSsrBuild ? { target: 'ES2022' } : {},
plugins: [
remix({
basename: '/admin/',
@@ -18,11 +17,11 @@ export default defineConfig({
babel({
filter: /\.[jt]sx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'], // if you use TypeScript
presets: ['@babel/preset-typescript'],
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig],
['babel-plugin-react-compiler', {}],
],
},
}),
],
})
}))